客户端的多线程

1.多线程中的对象


对象可能被多线程访问,而这里的线程可以分为两类:
对象内部为完成业务逻辑而创建的线程,线程需要访问对象。
对象外部使用该对象的线程。
如果更细一步划分,外部线程分为拥有者线程和其它线程,拥有者负责初始化该对象。


在此基础上,可以看看对象的生命周期。
对象的初始化可能在某个线程上,这个不讨论。对象生命周期在哪个线程上结束?


对象可以在内部线程上析构吗?
如果内部线程是完成业务逻辑,则对象不适合在这样的线程上析构,这样带来的逻辑关系就是
对象拥有线程,线程又控制对象的生命周期。好点的做法应该是对象在生命周期终止的时候,
中止这些内部线程。如果内部线程是一个GC线程,也就是设计中专门用来析构对象的,这取
决于具体设计。


这样看来对象应该在外部线程上析构?
在外部线程上析构有两种方法,一种是必须在拥有者线程上析构,另一种是在拥有最后一个引用
计数的线程上析构。在第二种析构策略中,拥有者线程除去在创建对象,初始化服务上表现得不
同,将所有的线程视为对等的。而第一种策略中,明确了服务应该在某个线程上管理。


第二种策略的实现一般引用计数就能控制。如果再时髦一点,可以加入弱引用(弱指针),
在需要使用该对象提供的服务时,提升为强指针。仔细想想,在生命周期问题上和直接用强指
针没啥大区别。直接使用强指针需要在该线程拥有这个对象的引用时,加一个引用计数,而弱
指针直到该线程需要访问对象时才提升为强指针,增加引用计数。对对象所在的析构线程没啥
影响,对象仍然可能在任意的外部线程析构。析构时会释放对象拥有的资源,或许,依赖于其
它资源,这就要求拥有的其它资源的线程安全性有一定保障,需要该对象知道这些资源的线程
安全特征。


换句话说,不推荐第二种策略是因为对其它资源的依赖,依赖组合在一起会提升系统复杂度。
使用其它技术也许能解决一些依赖,但是系统复杂度还是比较大。


所以,最简单的方法是在固定的线程上创建和销毁对象,其它线程可以使用该对象提供的服务。
于是非拥有者只持有弱指针,不能提升为强指针。于是有可能某些线程正在使用该对象,而拥有
者线程要求析构该对象。于是有两个办法,一种是依旧使用强指针,但是在检测到对象引用计数
为0时,应该切换线程析构,再一个办法是拥有该对象的一个代理,在调用方法时能跨线程调用。


第一种方法依旧要求对象提供的服务接口具有线程安全性,第二种方法则只需要认为对象在单线
程中工作。前者的难度在于提供有线程安全性的接口,以及切换线程的有效性(保证能切换到
其它线程,保证析构函数调用)。后者难度在于实现跨线程调用方法。


后者有一个简单的实现,调用方法时创建一个事件,然后生成一个闭包(事件在闭包中),通
过消息把闭包传到目标线程(要求目标线程有消息循环),目标线程完成工作后,把结果放在闭
包中,然后激发事件。在调用者线程中,可以同步等待,也可以等待超时。这不就是Future模式
么?


对于前者来说(提供线程安全性接口),一般都通过锁来实现,如果有多个锁,就需要注意按一
定顺序获取锁。如果在某个线程上获取锁后发生异常,整个对象就杯具了。切换线程一般也要求
目标线程有消息循环,通过消息切换线程。


(依赖于消息循环的线程切换在遇到消息循环被劫持时是比较恶心的,比如目标线程弹个框出来)


对于客户端开发,一般不会出现高并发,所以可以简单认为锁是OK的,跨线程调用的效率也尚可。
两个方案势均力敌。


对象提供一定的服务,服务的对象是谁?
一般而言,服务对象是业务逻辑。业务逻辑在设计上应该在同一个线程上。如果需要开线程或者
使用已知线程,往往在同一时刻,单个业务逻辑只在一个线程上工作。于是多线程访问对象,实际
上是不同业务逻辑访问对象,我们可以约定不同业务逻辑在同一个线程上访问该对象。在业务逻辑
的层次上做这样的拆分往往是可行的,除非对象提供的服务足够底层,面向的不是业务逻辑,随时
都可能用到该服务(类似于写一个new函数),除非业务逻辑对对象结果的实时性有较高要求:


设主逻辑线程为0,业务逻辑A需要服务对象S的服务。A调用S的服务后得到结果R,切换到线程1。
在线程1上工作的时候,S的状态发生变化,如果调用S,得到Rx,如果依赖于结果R进行处理否则这
个业务逻辑就会产生严重后果。这样的情况很少。


前面提到,单个业务逻辑只在一个线程上。如果单个业务逻辑同时需要多个线程,这个时候,多个
线程做的业务,不会用到这个服务。否则,说明业务逻辑和对象的服务设计上不合适,对象可能是
这个业务逻辑独占的,为这样的并发特殊处理即可。


至此为止,说明了一个问题:单个对象提供的服务,大多数时候只需要面向单线程即可。


在客户端开发,开多线程时,多线程间不是平等的:通常是需要做某件事,需要在某个线程上完
成一部分,完成后通知或回调。也有可能,做的事在线程上做完,并不需要通知或回调。也有可能
需要再次切换线程完成其它步骤。线程要么是worker要么是master,master只有一个。worker线程
之间一般没有任何直接关系,只接受任务并处理,或完成任务后结束线程。worker和master之间并
不平等。如果需要多个master,基本上可以断言,多个master之间的独立性很高,通过一些简单的
同步原语,就可以使得master之间协作。而不会有对象为多个master提供服务。


于是,客户端的线程模型是:多个平等且独立的master,一组worker线程。worker线程可以是被
某个master独占的,也可以是master之间共享的。每次做事都需要开线程时,这样的worker被master
独占。所有的master都向一组已经存在的worker派发任务时,这样的worker是共享的。


服务对象内部线程,其实是一个特殊的master-worker,因此问题归结于:如何设计master-worker.


而最常见的master-worker就是任务模型:master向woker派发任务,worker完成任务,回调任务,
任务可撤销。对于服务对象,意味着提供一个异步的接口,使用者调用接口时得到handle,任务完成
时,使用者收到回调。(对于服务对象内部不需要开线程的暂时不考虑)。对于整个系统,意味着
需要提供一个异步执行任务的机制,可以创建任务,取消任务,回调。master-woker在服务对象和
系统中需要解决的问题是一致的。另外,系统提供的异步机制,可以被看作特殊的一个服务对象。
如前面所述,这个特殊的服务对象,提供的是底层机制,而不是面向具体的业务逻辑,所以是一个
多线程的服务对象。写好这样的异步机制很困难,偷懒的办法是限制到一个master线程中访问,整个
世界就清静了。chromium的线程池是这样一个特殊的服务对象,提供了不同抽象层次的任务派发接口。
貌似要做到取消是困难的,于是把球踢给了使用者,需要使用者在提供任务时,要有所考虑。


于是系统是这样:
一个master线程,在上面有一个任务派发器,可以往已知的工作线程派发任务,可以新起线程来完
成任务,可以取消任务,派发器只能在master线程上访问。
master线程上还有其它若干对象,这些对象提供异步的接口。
所有的回调都在master线程上。


这样的系统可以解决大多数客户端软件开发中的问题。


不过,似乎其中单个业务逻辑会在不同的线程上存在,这样的业务逻辑对象,也有多线程访问的问题。
很好解决:这个业务逻辑作用任务派发器的时候,生成一个task对象,task对象中有业务逻辑的弱引用,
有整个任务的上下文的拷贝。有可能任务完成时业务逻辑对象已经销毁,这个时候只需要通过弱引用检
业务逻辑是否还存在。


于是如何设计master-worker的问题最终转换为:任务队列设计,切换到master线程。
而切换到master常用方法就是windows消息,所以最后问题是:任务队列。




2.任务队列设计
如前所述,该任务队列只在单独线程中访问。
在服务对象内部,常常是在初始化时开线程,线程接收服务对象传来的任务,没有任务时就等待。
通常只开一个线程。而对于任务派发器,可能会开多个线程,甚至有可能在收到任务时继续开线程。
在这里只讨论开一个线程,接受任务的情况。


一个基于线程消息的工作线程如下:


std::vector<Task> task_list;


while (GetMessage())
{
	TranslateMessage();
	DispatchMessage();
	
	switch (msg.message)
	{
	case WM_ADD_TASK: task_list.push_back(msg.wParam); break;
	case WM_DEL_TASK: 删除任务逻辑; break;
	}
	
	while (task_list.size())
	{
		if (PeekMessage()) break;
		
		从task_list中取出一个任务。
		执行任务。
	}
}




在这里,任务通过消息的wParam和lParam来传递,当然,在消息没有被收到的情况下,会造成内存
泄漏。
在任务执行过程中,是删除不了的,这需要加额外逻辑。


一方面需要增加删除标记,使得任务完成时不回调。此外有可能在任务已经完成的时候,正在回调
的过程中中止任务,这需要在master线程中再检测一次标记。


另一方面如果要保证任务即时退出,需要任务带一个StopEvent,在耗时处轮询这个StopEvent,
外部停止任务时,需要激发StopEvent。


另外,任务异常会影响整个任务队列。有必要的话,自己try catch一把。


到现在为止,任务队列没有锁,或者说被一些windows的机制掩盖了锁的存在。






再来个任务队列:


HANDLE events[] = {停止事件,有任务事件};
for (;;)
{
	if (停止事件发生) break;
	
	Task task;
	if (!LoadTask(&task))
	{
		等待停止事件和有任务事件。
		continue;
	}
	
	do task;
}



在这里任务被放在一个队列中,对队列的访问应该是互斥的。




任务队列还有很多变形:单个任务的任务队列,后面的任务可以把前面的任务替换。任务
带上优先级。


3.chromium的线程机制
参与者:
Thread:
线程对象的封装.
在执行时会在内部使用MessageLoop进入对应的循环.
对外提供mesage_loop_proxy使得可以往该线程上派发任务.
对外提供接口访问关联的MessageLoop的指针,弱.

MessageLoop:
消息循环.
聚合MessagePump进行消息循环.
维护task队列,在响应pump的回调时处理任务队列.
构造时将自己写到tls中,向外提供静态方法获取当前纯种的MessageLoop.
结束时剩余任务不作处理.

该类提供的派发任务接口只建议在当前线程使用.


MessagePump:
消息泵.
负责起消息循环,调度MessageLoop.

MessageLoopProxy:
消息循环代理.
和某个MessageLoop绑定,提供向该MessageLoop派发任务的接口.
提供接口获取当前线程上的MessageLoop的MessageLoopProxy.

通过代理可以在任意线程上向指定线程派发任务,可以把代理传到某个对象中保存起来,
然后调度时就向固定的线程派发任务,而不必知道具体是哪个线程.
代理指向MessageLoop内的MessageLoopProxy,通过引用计数维护生命周期.
代理向MessageLoop派发任务时会保证MessageLoop一定存在,或者检测到MessageLoop已析构时返回失败.


BrowserThread:
线程池,维护固定类型的线程。
提供向具体线程派发任务,获取指定线程上的message_loop_proxy等接口.

派发任务时通过原子访问全局对象中注册的线程将任务派发到指定线程.
提供的MessageLoopProxy的实现只保存对应的线程ID,在派发任务时调用PlayerThread的派发接口.
和前面的代理类相比,不参与MessageLoop内部的代理类的生命周期管理,但是和需要原子地访问全局对象.
在访问全局对象时通过一定的手段,在某些情况下做到无锁访问.


Chromium的线程主要是指这里的通过固定类型能够访问的线程。(通过Pool维护无固定类型的线程并提
供对应的调度接口暂不讨论)


可以看出,调度接口分三个级别:
线程池级别,代理级别,具体线程(消息循环)
根据自己需要选择不同级别的接口.不同的级别有不同的安全性保证和需求.


为了支持任务,还引入了Bind机制:


Bind:
支持普通函数和成员函数,使用成员函数时第一个参数为对应的对象指定,且要求对象是ref counted的.

Bind后返回Callback对象,如果一个Callback对象的返回类型是void且参数类型也是void,则称这个Callback为Closure.
在线程池处理的任务均为Closure.

在Callback内部将函数指针,参数全部保存起来,然后在调用时将参数应用到函数指针上.
因此,如此参数是非常引用,表达出直接修改某个对象的语义时和Bind的实现相悖,不支持.通过静态断言阻止这种做法.

成员函数调用时,在保存参数时会增加对象的引用计数,调用完成后减少引用计数.

对于scoped_ptr,scoped_array,scoped_ptr_malloc,ScopedVector类型的参数,会通过构造的move语义支持进行优化.

因此建议线程池上的任务,要少引用外部的东西,比如绑定到成员函数则将对应对象引用计数加1,又如参数为智能指针
时会将智能指针的引用计数加1.这样,任务没有执行而应用程序退出时,会在线程池的反初始化时再释放这些对象,
可能存在问题.所以,任务对外部依赖要足够少.

下面是一个做到外部依赖足够少的参考解决方案:
比如A类要干某件事,可以考虑建一个B类,B类负责干这件事,存储一些数据,同时有一个指针指向A.A需要干事的时候
就new一个B类的对象,并保存在A的智能指针成员中.A类对象析构时,调用B类的接口告诉B,A已经析构了.B类完成任务
时,检查一次A是否析构,如果析构则什么也不做(如果通过成员变量标记,本次访问该标记时不太靠谱,因此该线程在
读,另一个线程可能在写.这里访问只起优化).B类回到A调用B的线程时,再一次检查A是否析构,如果析构则什么也不
做,否则可以处理业务逻辑.这一次对标记的检查会是安全的,因此A也只在这个线程中保证写标记.

由于B类的独立性高,所以容易做到可以在任意时刻析构.


Chromium的线程池分三类:
Default只负责处理任务队列。
UI会起消息循环,同时处理任务队列。
IO能够检测到IO的完成或JOB的信息,总之,能挂在完成端口上的就能检查,同时处理任务队列。


其实现难度主要是在其中的pump上:


Default的pump:
有Work时保证有再Work一次的机会。
有DelayedWork时保证Work和DelayWork都有再处理的机会。
否则,认为可以处理IdleWork。
如果没IdleTask,根据一定策略计算等待时间,等待到新任务或有DelayTask处理。
MessageLoop在加任务时执行ScheduleTask,唤起可能的等待。

UI的pump:
如果仅通过派发线程消息来通知任务,则会存在问题:UI消息循环中处理消息时再起消息循环,使得
无法收到派发给该线程的消息。所以,需要在线程上建窗口来处理消息。

一个简单的实现就是每加一个任务则Post一个任务消息到这个窗口,但是chrome在此基础上作了优化:
保证消息队列中至多只有一个任务消息。

首先用一个变量have_work_表示有任务消息待处理的状态,为1时表示有任务消息需要处理,0表示
没有任务消息需要处理。而该变量的维护通过原子操作实现,有资料表明比临界区快一倍。发任务
消息时,如果发现have_work_为1,则什么都不做。消息循环从消息队列中取出任务消息时,则将
have_work_置为0。

然而,这里存在竞争关系,当消息循环从消息队列中取出消息时,have_work_没有被设置为0。在此
同时添加任务,发现have_work_还是1,于是就不发消息。如果在have_work_被设置为0后,进入空闲
状态,则有消息丢失。

所以在通过窗口过程中确定处理一个任务后还要额外再ScheduleTask一把。

由于这里会自己处理消息,所以还有所优化。在有任务消息的时候,不需要派发到窗口过程。而是在
自己的循环中一方面处理一条非任务消息,然后调度一次任务,交替进行。所以上述的窗口过程中处理
消息一般情况下调用不到。

在处理消息的时候有两种情况,一种是任务消息,一种是其它消息。在处理任务消息时,和要处理非
任务消息的设计不合,所以还会从消息队列里再拿一次消息。而对于其它消息,则处理一次,返回true。

在消息处理函数后紧接着就是任务队列的处理,两者不能交换。如果在处理完work后,其它线程加一
个任务进来,这时消息处理函数发现只有一个任务消息,于是返回false,可能最终陷入等待消息的状态,
使得这个消息没有即时处理。

在没有消息,且没有Work(含Delay)需要处理时,认为可以处理一下IdleWork。

IO的pump:
当有Work时使得Work还有一次执行的机会。
有任何完成事件时,又使得Work有执行的机会,且完成端口有检测的机会。
有DelayWork的时候,前面两者和DelayWork本身有处理的机会。
否则,处理IdleWork和等待,等待时根据策略计算等待时间。


外部通过向完成端口发消息唤醒可能的等待。

IO的pump同样保证至多有一个task的完成事件在队列中。如果事件被取出,且标志没有被置为0,没有关系。
因为任何完成事件都使得Work有执行的机会。

客户端的多线程,古老的榕树,5-wow.com

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。