本文介绍并发程序中经常遇到的几个经典问题以及解决方法。

1 Thundering herd problem(惊群效应)

解决方法:

  1. Event Multiplexing: Use event-driven programming models (e.g., select, poll, epoll in Linux) that allow a single thread or process to handle multiple events.

  2. Load Distribution: Distribute the load more evenly among multiple processes or threads using techniques like load balancers. Load balancing involves distributing incoming requests across multiple servers to prevent any one server from becoming overwhelmed. By distributing the load across multiple servers, the likelihood of a thundering herd problem occurring can be reduced.

  3. Serialized Access: Implement locking mechanisms or use queueing to serialize access to the shared resource, ensuring that only one process or thread handles the event at a time. Queuing: Queuing involves placing incoming requests in a queue and processing them in a controlled and orderly manner. By queuing requests, the load on the resource can be controlled, and the likelihood of a thundering herd problem occurring can be minimized.

  4. Rate Limiting: Introduce rate limits to control the frequency at which processes or threads are woken up. Throttling involves limiting the rate at which requests are processed. By limiting the rate at which requests are processed, the load on the resource can be controlled, and the likelihood of a thundering herd problem occurring can be minimized.

  5. Connection pooling: Connection pooling involves reusing existing connections to a resource rather than creating a new connection for each request. By reusing existing connections, the load on the resource can be reduced, and the likelihood of a thundering herd problem occurring can be minimized.

2 lock problem

2.1 deadlock(死锁)

2.2 livelock(活锁)

活锁产生的背景是:若获取多个锁时,若无法获取全部的锁时,会释放已经持有的锁,防止死锁。

下面考虑这种场景:
线程1(t1)需要获取lock1和lock2才能工作。线程2(t2)也需要获取lock1和lock2才能工作。
t1先拿到lock1,尝试去获取lock2;t2先拿到lock2,尝试获取lock1;如果t1获取lock1和t2获取到lock2是同时发生的,他们在尝试获取另外一个锁时都会失败,然后同时释放自己的锁后重新按照原流程进程尝试。这样就会导致两个线程一直重试而无法继续进行。

活锁类似于生活中两个有礼貌的人让路的场景:两个人都很客气的让路给对方,但是两人同时移动到同一侧,又继续冲突,再移动回来又冲突,一直这样持续下去,那么就会发生活锁。

2.3 Starvation(饿死)

2.3 Lock convoy

2.5 Pessimistic Concurrency Control(悲观锁)

悲观锁策略:认为修改读写数据是不安全的,需要在操作数据前加锁,操作完数据后再解锁。也就是传统意义上的加锁动作。

2.6 Optimistic Concurrency Control(乐观锁)

乐观锁的策略:乐观锁在更新数据前先获暂存数据,然后执行修改数据的操作。
在将数据写回时,确认数据是否被修改,若数据被修改,就放弃更新的动作。

乐观锁,一般用在数据冲突比较小的场景。

2.7 False Sharing(伪共享)

2.4 Non-blocking algorithm

2.4.1 ring buffer FIFO

a single-reader single-writer

2.4.2 disruptor

2.4.2.1 disruptor 的原理介绍

How the Disruptor works and how to use it
disruptor的原理中文翻译

2.4.2.2 disruptor的ringbuffer结构

  1. 内部结构为数组实现的环状队列,大小一般设置为$2^n$个,这样可以通过使用&运算替代%运算,加快计算速度。例如对于有8($2^3$)个数据的环状队列。 只需要将数字编号n&7(8-1)即可得访问对应的内容。
  2. 其中的数据的编号从0开始,不断增长。随着你不停地填充这个buffer(可能也会有相应的读取),这个序号会一直增长,直到绕过这个环。

2.4.2.3 从ring Buffer中读数据的原理

假设Ring Buffer中已经写入了一些数据,如下图所示,怎样从Ring Buffer读出这些数据呢? 读数据示意图1

这里增加几个基本概念:

  • 消费者(Consumer):消费者(Consumer)是一个想从Ring Buffer里读取数据的线程。就像Ring Buffer需要一个序号才能找到下一个可用节点一样,消费者也需要知道它将要处理的序号——每个消费者都需要找到下一个它要访问的序号。  

  • ConsumerBarrier对象: ConsumerBarrier是消费者和Ring Buffer沟通的桥梁,用于两者之间的通信。 这个对象由RingBuffer创建并且代表消费者与RingBuffer进行交互。

消费者可以调用ConsumerBarrier对象的waitFor()方法,传递它所需要的下一个序号.

1
final long availableSeq = consumerBarrier.waitFor(nextSequence);

在上面的例子中,消费者处理完了Ring Buffer里序号8之前(包括8)的所有数据,那么它期待访问的下一个序号是9。 但是ConsumerBarrier从RingBuffer获取到的最大可访问序号是12。如下图所示:
读数据示意图1

注意

  1. 对于这个环状队列,其中的编号要经过对环的大小取余,才是其实际的编号。
    针对该示例,RingBuffer返回的最大可访问序列号是12(实际对应的数组编号是2),而消费者期望访问的编号是9。 所以消费者需要等待数据填充到9以及已有的区域时才可以访问。(实际分析问题是,可以认为只能按照顺时针去访问数据,不能逆时针访问。)

  2. ConsumerBarrier有一个WaitStrategy方法来决定它如何等待这个序号

接下来怎么做?
接下来消费者会一直原地停留,等待更多数据被写入Ring Buffer。并且一旦数据写入后消费者会收到通知——节点9,10,11和12 已写入。现在序号12到了,消费者可以让ConsumerBarrier去拿这些序号节点里的数据了。如下图所示:

读数据示意图1

2.4.2.4 向ringbuffer中写数据的原理