如果你用过Boost Asio,那你肯定用过或者见到过strands。
在编写异步代码时,函数时常会被多个线程并发调用。为了避免函数对同一份资源的竞争,我们可能需要使用诸如互斥量的办法来显式地限制对资源的访问,这无疑为程序的设计和代码的编写带来了不必要的压力。
而Strands正是解决此类问题的一种设计,它可以对一组函数的执行进行调度,保证这一组函数不会并发执行。通过Strand执行的函数不需要显式地同步,这简化了异步代码的编写。
在程序中,如果你只有一个IO线程(比如在Boost::Asio中,只有一个线程调用了 io_service::run),那么你并不需要同步。这种情况下该线程中所有的函数会依次执行。但是当你希望提高处理速度分配了多个IO线程时,那资源的竞争问题要求你显式地同步函数的执行,或者使用Strands之类的设计。
显式地同步函数执行是可能的,但是这会引入不必要的复杂性到代码中,也可能因此带来bugs。
在一般的任务处理队列中,工作线程会拿到队列中函数并执行。Strands通过在工作线程和函数执行间引入中间层,保证了函数的执行顺序,避免了对资源的竞争。
示意图如下:
可能的场景
Remotery可以帮助我们可视化线程执行和函数调用的情况。
测试代码模拟了4个工作线程和8个连接。每个连接会给出一组计算任务到任务队列中,每个任务耗时5 ms到15 ms。尽管这与实际情况下的线程/连接比和任务耗时有所出入,但是这个例子可以很好的解释这个话题。
阻塞问题
现在看一下每个线程随时间的执行情况:

每一个连接Conn N都用不同的颜色标记出来,连接提交的任务在不同线程中处理。
如果我们观察每个时间片上线程的运行情况:

工作线程会从任务队列中取出每个连接提交的任务并执行,当一个线程正在处理一个连接提交的任务时,如果另外一个线程也在处理这个连接提交的任务,那么后者会被阻塞(避免资源竞争)。
时间记录如下:

在上述场景中,工作线程大约19%的时间都被阻塞浪费掉了,换句话说,只有81%的时间是在真正处理任务。
如果我们使用Strand帮助我们同步每一个连接的任务:


只有非常少的时间被内部工作和同步问题浪费了。
缓存局部性
另一个可能的好处是更好的CPU缓存利用(cache utilization)。工作线程会倾向于处理同一个连接的多个任务后再处理另一个连接。
没有使用Strands时的执行情况

使用Strands时的执行情况

尽管实际工作中函数处理的情况并不像测试假设的这样,但是测试中的这种结果也表明了Strand可能的好处。
Strands实现
作为一个练习,这个实现并不能达到工业强度,但是可以很好地用于我们的实验。
首先让我们定义Stands需要完成的功能:
没有函数可以并发执行
这要求我们检测是Stands是否在某个线程中运行
为了避免阻塞,Stands需要一个内部的任务队列用于确保函数能正确的执行
函数只在对应的工作线程中被执行
- 这要求其他线程中提交的任务正确地被加入到Stands的任务队列中
函数的执行顺序没有保证
- 我们可能从多个线程中提交任务到Stands的任务队列中,所以任务的执行顺序并没有保证
与Boost::Asio::Strands相似,我们为Stands定义了3个主要的接口:
- post——将任务加入到队列中,稍后执行;
- dispatch——允许时立刻执行任务,否则将任务加入到队列中;
- run——处理所有的任务。
先不考虑同步的问题,我们可以绘制出这3个方法的行为:



为了推导全部的代码,我们需要用到一些在前面的文章中介绍的辅助类:
Callstack——允许我们在当前的调用栈中放置标记,用于检测我们是否正在当前线程中执行某个函数;
WorkQueue——简单的多消费者/多生产者任务队列。当没有任务时消费者会阻塞;
Monitor
——对T类型的对象的并发访问提供同步控制。
具体的推导如下:
1 |
|
为了减少代码依赖,Strands被设计为模板类,你必须指定一个合适的Processor类。
值得再次强调的是,Strands并不是用Processor来执行函数,而是用它来执行自己的Run方法。
使用示例
1 |
|
测试结果如下:
1 | Obj 2 : doing 0 |
总结
使用Strand有明显的优势:
不在需要显示同步
- 对于绑定了同一个Strands的一组函数,我们不必再担心并发执行的问题
更少的阻塞
- 降低了对同一个连接的资源竞争,如在TCP连接中Buffer的读写顺序控制
缓存局部性
- 取决于具体的场景,但好过没有
附: 测试项目