无锁多线程控制基本模型
无锁多线程程序不会阻塞,每个线程在同一时刻对同一段代码同时执行,仅依靠原子变量实现线程间同步。无锁程序中,原子变量可以实现三种功能:
- 引导不同线程执行不同的分支
- 标记程序、线程和数据的状态
- 作为数据的载体
在无锁程序中,每个线程大都是对等的,即不存在专门的生产者和消费者线程,不存在服务器客户端。每个线程既是生产者也是消费者,既是服务器也是客户端。每个线程依据此时的程序状态(用原子变量表示)作为生产者或消费者,或同时执行生产者或消费者功能。
下面介绍无锁多线程常用执行模型。
载体模型
每个线程均不能看到其他线程、数据的暂态,因此要求线程对某一全局数据的修改仅在原子变量更改的一瞬间完成。即该原子变量为全局数据的载体。用原子变量作为数据载体的常见方式如下表:
原子变量类型 | 荷载数据 | 用途 |
---|---|---|
uint64_t及以下 | 算术变量、枚举变量 | 单一变量表示程序状态 |
数组index 数组offset | class、struct | 数组的每个元素为class,存储若干变量,这些变量为一个整体,随状态同时改变。 |
指针 | 任意数据 | 部分原子指针的底层实现可能有锁,且需要自行管理内存,难度较大。 |
一旦某个原子变量所关联的数据公布于所有线程,则该数据就为只读的。因为一旦某个线程修改了公布的数据,则其他线程就可能在修改时读取,引发读写竞争。有两种方式修改全局数据:
- 如果某个线程需要修改已经公布的数据A,则必须先将原子变量切换到另一个公布的数据B上;确保自己独占数据A后,对A进行修改;A修改完毕后,再将数据A公布于其他线程。在A数据被修改的整个过程中,其他线程均读取数据B进行操作。
- 线程将原子变量表示的数据A拷贝到一个私有空间中,这个空间可能是数组的某个元素(该元素不公布于其他线程),然后对私有空间进行修改,最后将原子变量切换到这个私有空间。
“读-修改-写”模型
如果某个线程需要修改全局数据,其先将原子变量表示的数据拷贝到私有空间中,对私有空间进行修改,待修改完毕后,准备执行原子变量替换以公布私有数据,这时待修改的原子变量可能已经被其他线程抢先一步修改。那么该线程只能重复修改步骤,再次尝试在其他线程已修改的基础上修改数据,直至没有其他线程抢先修改为止。伪代码如下:
//1. 读取原子变量
atomic a;
auto as = a.load();
do
{
//申请私有空间,index=b, 读取数据A
Object &A = array[b]
A = array[as]; //拷贝到线程私有空间中
//2. 修改数据
A.modify(); //A数据可能有损毁,需要考虑。
//如果损毁则说明读取数据时出现竞争,需要重新读取
if(A.error())
{
a = atomic.load();
continue;
}
} while(!a.compare_exchange_strong(as, b)); //3. 发布
如果在修改数据的过程中,有其他线程抢先修改了原子变量a,则发布时就会判定a和as值不相等,b值不会替换到a中,compare_exchange_strong返回失败。否则a和as值相等,b值会替换到a,compare_exchange_strong返回成功,while循环退出。
上述代码中array为FIFO容器(如数组循环队列),其空闲槽位index为一个原子变量,每当一个线程申请array元素时,均采取“读-修改-写模型”,从FIFO容器中获取一个空闲槽位。如果该空闲槽位被其他线程抢占,则重复先前步骤重新获取,直至无抢占成功获取为止。FIFO容器的大小取决于并发度,依实际工程计算并设置。
当线程成功将a替换b值后,as值就为当前线程私有了,可以将as的值退回给array数组容器,表示此时as为空闲槽位。退回的过程同样采取“读-修改-写模型”追加到数组末尾或头部。
数据竞争问题避免
除非原子变量不关联其他全局数据,否则在“读-修改-写”模型中,可能存在数据竞争问题:一个线程正在读取数据A,而此时的as值可能已经被另一个线程私有了,正在进行array[as]的退回。“读-修改-写”模型的每个语句(直至while以后)都存在若干线程的并行执行。
当竞争发生后,一般很难判断数据是否损毁,常见的做法是避免数据竞争。应该保持array[as]不变直到所有持有as值的线程释放为止,这个过程可采用将array[as]设置为智能指针解决。借助智能指针引用计数是线程安全的特性,可以确保只有一个线程对array[as]指向的对象进行析构,但智能指针指向的对象是全程只读的。
任何被多个线程读取的公开数据都应该是只读的,且这部分数据必须先转为私有,再确认没有任何线程占有后,才能被安全释放。数据释放的时机为最后一个线程释放时,因此存在某个数据的开辟在一个线程中,但释放在另一个线程中。无论数据是否私有,从公开到释放必须全程保持只读。
抢占模型
“读-修改-写”模型的简化是抢占模型,针对一个数组空间,每个线程都要抢占其上的一部分空间,直至数组抢空为止程序停止。抢占模型不要求线程释放占有的空间,一旦线程抢占就永久占有直至进程结束。从array中抢占大小为len的空间,该模型的伪码如下:
atomic freepos = 0;
auto pos= freepos.load();
while(!freepos.compare_exchange_strong(pos, pos+len)) ; //增大freepos = freepost + len
//表示抢占成功
//独占array+pos起始,长度为len的空间
抢占模型的问题
先抢占后写入是抢占模型的问题,如果有其他线程需要依照抢占顺序依次读取数据至freepos,那么就会产生数据竞争。此处的矛盾在于:当空间被抢占以后,意味着该空间已经公布于其他线程,该空间就应该有数据且只读,但无法在抢占前将数据写入空间,因为此时还未知空间的地址。
矛盾的原因是抢占模型中原子变量freepos不能标识已写入空间的范围,freepos的功能仅限于控制线程抢占空间,不意味着向读取线程公布已写范围。若需要告知读取线程已写范围,需要新建原子变量作为已写范围的载体,新建的原子变量应该在数据写入以后更改。
atomic writtenpos = 0; //向读取线程公布已写范围
atomic freepos = 0;
auto pos= freepos.load();
while(!freepos.compare_exchange_strong(pos, pos+len)) ; //增大freepos = freepost + len
//表示抢占成功
//数据写入array+pos起始,长度为len的空间
…
//更新writtenpos
auto written = pos;
do {
written = pos;
} while(!writtenpos.compare_exchange_strong(written, pos+len));
更新writtenpos也采用“读-修改-写”模型,每个写入线程仅更新各自已抢占空间的已写范围。所有写入线程形成调用链,前一个线程更新writtenpos后将触发下一个线程更新writtenpos。
更一般地,多个线程抢占任务,如下是几种标记任务完成情况的方式:
原子变量类型 | 含义 | 方法 |
---|---|---|
uint64_t及以下 | 原子变量作为bitmap | 每个bit表示一个任务状态,线程遍历bit获知完成情况 |
算术类型 | 已完成任务的范围 | 任务完成方采用“读-修改-写”以调用链方式更新该原子变量 |
算术类型 | 已完成任务个数 | “已完成任务个数==已抢占任务个数“时,表示所有任务已完成;每个线程完成任务后递增原子变量 |
指针 | 指向状态class | 指针指向的class标记了当前完成状态,任务完成方创建class,状态读取方获取class内容,需要自行管理内存 |
状态机模型
多个线程运行时,有时可能要求其中一个线程执行某个特殊工作。对于无锁程序,一般会随机选取一个线程执行这个特殊任务。选取线程的过程可用状态机模型描述。
定义程序的状态为[状态1, 状态2, 状态3……],可以用枚举标识状态列表,并用一个原子变量表示状态。初始时设置某个状态,线程并行进入某个状态,但只有一个线程准许切换程序状态,即该状态执行完毕后,切换程序到另一个状态。在程序的每个状态下,都存在若干线程并行执行,一部分线程并行执行某个任务,另一部分线程并行执行另一个任务。状态机模型的伪码如下:
enum State
{
STATE1 = 0,
STATE1_SWITCH = 1,
STATE2 = 2,
STATE2_SWITCH = 3,
…
};
atomic state = STATE1;
do {
State lastState = STATE1;
if(state.compare_exchange_strong(lastState, STATE1_SWITCH))
{
//唯一执行状态切换1的线程
state.store(STATE2);
}
else if(lastState == STATE1_SWITCH)
{
//并行执行状态1任务
}
else if(state.compare_exchange_strong(lastState, STATE2_SWITCH))
{
//唯一执行状态切换2的线程
state.store(STATE3);
}
else if(lastState == STATE2_SWITCH)
{
//并行执行状态2任务
}
...
} while(…);
初始时所有线程均抢占进入STATE1_SWITCH,但只有一个线程A可以进入,当线程A进入后,其他线程转入状态1并行执行并反复do…while循环。线程A执行完毕后,切换程序状态到STATE2,然后其他线程在新一轮do…while循环时开始抢占进入STATE2_SWITCH,类似STATE1_SWITCH过程。
状态机模型的核心思想是通过原子变量state控制所有线程从一个状态切换到另一个状态,在每个状态下又可以独立控制某个线程单独执行任务或所有线程并发执行任务。对于单独执行的任务,如上例中第一个if分支,其内部无需考虑并发引起的竞争问题,仅从单线程角度编码即可。而下一个else if分支以及“读-修改-写”模型的每一个语句均需要考虑多线程并发执行的问题。