无锁编程

阅读 / 问答 / 标签

高并发情况下怎样尽量实现无锁编程

  一个在线2k的游戏,每秒钟并发都吓死人。传统的hibernate直接插库基本上是不可行的。我就一步步推导出一个无锁的数据库操作。    1. 并发中如何无锁。  一个很简单的思路,把并发转化成为单线程。Java的Disruptor就是一个很好的例子。如果用java的concurrentCollection类去做,原理就是启动一个线程,跑一个Queue,并发的时候,任务压入Queue,线程轮训读取这个Queue,然后一个个顺序执行。  在这个设计模式下,任何并发都会变成了单线程操作,而且速度非常快。现在的node.js, 或者比较普通的ARPG服务端都是这个设计,“大循环”架构。  这样,我们原来的系统就有了2个环境:并发环境 + ”大循环“环境  并发环境就是我们传统的有锁环境,性能低下。  ”大循环“环境是我们使用Disruptor开辟出来的单线程无锁环境,性能强大。    2. ”大循环“环境 中如何提升处理性能。  一旦并发转成单线程,那么其中一个线程一旦出现性能问题,必然整个处理都会放慢。所以在单线程中的任何操作绝对不能涉及到IO处理。那数据库操作怎么办?  增加缓存。这个思路很简单,直接从内存读取,必然会快。至于写、更新操作,采用类似的思路,把操作提交给一个Queue,然后单独跑一个Thread去一个个获取插库。这样保证了“大循环”中不涉及到IO操作。    问题再次出现:  如果我们的游戏只有个大循环还容易解决,因为里面提供了完美的同步无锁。  但是实际上的游戏环境是并发和“大循环”并存的,即上文的2种环境。那么无论我们怎么设计,必然会发现在缓存这块上要出现锁。    3. 并发与“大循环”如何共处,消除锁?  我们知道如果在“大循环”中要避免锁操作,那么就用“异步”,把操作交给线程处理。结合这2个特点,我稍微改下数据库架构。  原本的缓存层,必然会存在着锁,例如:  public TableCache  {  private HashMap<String, Object> caches = new ConcurrentHashMap<String, Object>();  }  这个结构是必然的了,保证了在并发的环境下能够准确的操作缓存。但是”大循环“却不能直接操作这个缓存进行修改,所以必须启动一个线程去更新缓存,例如:  private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();  EXECUTOR.execute(new LatencyProcessor(logs));  class LatencyProcessor implements Runnable  {  public void run()  {  // 这里可以任意的去修改内存数据。采用了异步。  }  }  OK,看起来很漂亮。但是又有个问题出现了。在高速存取的过程中,非常有可能缓存还没有被更新,就被其他请求再次获取,得到了旧的数据。    4. 如何保证并发环境下缓存数据的唯一正确?  我们知道,如果只有读操作,没有写操作,那么这个行为是不需要加锁的。  我使用这个技巧,在缓存的上层,再加一层缓存,成为”一级缓存“,原来的就自然成为”二级缓存“。有点像CPU了对不?  一级缓存只能被”大循环“修改,但是可以被并发、”大循环“同时获取,所以是不需要锁的。  当发生数据库变动,分2种情况:  1)并发环境下的数据库变动,我们是允许有锁的存在,所以直接操作二级缓存,没有问题。  2)”大循环“环境下数据库变动,首先我们把变动数据存储在一级缓存,然后交给异步修正二级缓存,修正后删除一级缓存。  这样,无论在哪个环境下读取数据,首先判断一级缓存,没有再判断二级缓存。  这个架构就保证了内存数据的绝对准确。  而且重要的是:我们有了一个高效的无锁空间,去实现我们任意的业务逻辑。    最后,还有一些小技巧提升性能。  1. 既然我们的数据库操作已经被异步处理,那么某个时间,需要插库的数据可能很多,通过对表、主键、操作类型的排序,我们可以删除一些无效操作。例如:  a)同一个表同一个主键的多次UPdate,取最后一次。  b)同一个表同一个主键,只要出现Delete,前面所有操作无效。  2. 既然我们要对操作排序,必然会存在一个根据时间排序,如何保证无锁呢?使用  private final static AtomicLong _seq = new AtomicLong(0);  即可保证无锁又全局唯一自增,作为时间序列。

无锁编程与分布式编程那个更适合多核CPU

无锁编程主要是使用原子操作替代锁来实现对共享资源的访问保护,举个例子,要对某个整数变量进行加1操作的话,用锁保护操作的代码如下:int a = 0;Lock();a+= 1;Unlock();如果对上述代码反编译可以发现 a+=1;被翻译成了以下三条汇编指令:mov eax,dword ptr [a]add eax,1mov dword ptr [a],eax如果在单核系统中,由于在上述三条指令的任何一条执行完后都可能发生任务切换,比如执行完第1条指令后就发生了任务切换,这时如果有其他任务来对a进行操作的话,当任务切换回来后,将继续对a进行操作,很可能出现不可预测的结果,因此上述三条指令必须使用锁来保护,以使这段时间内其他任务无法对a进行操作。需要注意的是,在多核系统中,因为多个CPU核在物理上是并行的,可能发生同时写的现象;所以必须保证一个CPU核在对共享内存进行写操作时,其他CPU核不能写这块内存。因此在多核系统中和单核有区别,即使只有一条指令,也需要要加锁保护。如果使用原子操作来实现上述加1操作的话,例如使用VC里的InterlockedIncrement来操作的话,那么对a的加1操作需要以下语句InterlockedIncrement (&a);这条语句最终的实际加1操作会被翻译成以下一条带lock前缀的汇编指令:lock xadd dword ptr [ecx],eax使用原子操作时,在进行实际的写操作时,使用了lock指令,这样就可以阻止其他任务写这块内存,避免出现数据竞争现象。原子操作速度比锁快,一般要快一倍以上。从上表的四个方面的综合比较可以看出,无锁编程的实用价值是远远不如分布式编程的,因此分布式编程比无锁编程更适合多核CPU系统。