客户端的多线程

来源:互联网 发布:苹果下歌用什么软件 编辑:程序博客网 时间:2024/06/06 12:45
1.多线程中的对象


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


在此基础上,可以看看对象的生命周期。
而生命周期的开始是容易确定的,但是对象生命周期在哪个线程上结束?


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


1.2对象的并发性。
对象的并发性是指是否可以在多线程中调用对象的方法。允许多线程调用时有两种模式,一种
是每个线程中直接调用对象的方法,另一种是在一个线程中直接调用对象的方法在其它线程拥
有对象的代理,通过代理调用对象的方法时动作在能直接调用方法的线程内执行。


第一种模式要求对象提供的服务接口具有线程安全性,会提高对象的复杂度。一般都通过锁来
实现,如果有多个锁,就需要注意按一定顺序获取锁。如果在某个线程上获取锁后发生异常,
整个对象就杯具了。


而第二种模式只需要认为对象在单线程中工作,但是需要额外的跨线程调用的机制。该模式有
一个简单的实现,调用方法时创建一个事件,然后生成一个闭包(事件在闭包中),通过消息
把闭包传到目标线程(一般要求目标线程有消息循环或EventLoop),目标线程完成工作后,把
结果放在闭包中,然后激发事件。在调用者线程中,可以同步等待,也可以等待超时。这不就
是Future模式么?


1.3对象的析构策略。
在第一种模式中,可以在最后一个拥有引用计数的线程中析构,也可以将析构动作强行推到固定
线程中析构(只考虑固定线程为拥有者线程的情况)。


[如果再时髦一点,可以加入弱引用(弱指针),在需要使用该对象提供的服务时,提升为强指针。
仔细想想,在生命周期问题上和直接用强指针没啥大区别。直接使用强指针需要在该线程拥有这
个对象的引用时,加一个引用计数,而弱指针直到该线程需要访问对象时才提升为强指针,增加
引用计数。对对象所在的析构线程没啥影响,对象仍然可能在任意的外部线程析构。]


如果在最后一个拥有引用计数的线程中析构,析构时会释放对象拥有的资源,或许,依赖于其它
资源,这就要求拥有的其它资源的线程安全性有一定保障,需要该对象知道这些资源的线程安全
特征。依赖组合在一起会提升系统复杂度。使用其它技术也许能解决一些依赖,但是系统复杂度
还是比较大。所以,在系统复杂的情况下,倾向于使用在固定线程中析构的策略,也就要求有在
某个线程中执行析构函数的机制。


在第二种模式中,由于非拥有者线程拥有的是代理,在语义上不会影响对象的生命周期,也就是
说代理生命周期可以大于对象生命周期,只是在调用方法时发现对象已经销毁而已。因此该模式
下一般认为析构动作在拥有者线程中。


通过两种模式的比较,我们都发现需要区分拥有者线程和使用者线程。其中在模式一中,拥有者
线程的存在性主要是析构时的依赖所引入的,否则,除去拥有者创建对象以外,拥有者线程和使
用者线程没有区别。


在两种模式的比较中,隐隐约约都出现了在指定线程上调用方法的动作。




1.5考虑一下使用者如何调用对象的方法。
一般而言,使用者是业务逻辑。(对象本身也是业务逻辑的一种,只是可能处在较低的层次而已)
如果某个业务需要开线程或者使用已知线程,往往在同一时刻,逻辑只在一个线程上工作。
看一个例子,根据某本书从服务器拉取和书相关的推荐信息。拿到本地的书的信息需要调用某个对象
的方法,我们在业务发起线程中拿到书的信息,在IO线程中下载,然后到COMPUTE线程中解析结果,回
到发起线程中发出推荐消息。整个业务分为几个阶段,同一个阶段只存在于一个线程。而对书的信息
的获取,只在最开始获取一次,不是等到IO线程中再去查询本地的书的信息。我们的本地的书的信息
只能在一个线程中访问。


客户端的业务中难以遇到平等的多线程(平等的多线程的例子是完成端口的服务线程)。开多线程
的意义在于不会阻塞线程,而为了实现这点,应该把业务分为若干步,每一步放到一个线程里去做。


在业务逻辑的层次上做这样的拆分往往是可行的,除非对象提供的服务足够底层,面向的不是业务
逻辑,而是随时都可能用到该服务(比如面向语言,提供存储分配服务),除非业务逻辑对结果的
实时性有较高要求:


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


所以:单个对象提供的服务,大多数时候其使用者只在同一个线程里使用服务,服务本身面向单线程。
所以,从使用者的角度来看,对象面向单线程是足够用的,但是也需要在指定线程上调用方法的机制:
业务逻辑本身的线程切换。


所以我们都希望有机制能够在指定线程上调用方法。


1.6怎么实现在指定线程上调用方法。
a.所有的线程都有某个循环,比如任务循环。
b.实现派发器,派发器能够向任何线程派发任务。
c.人为地将一个线程标记为主线程,也就是master,其它线程是worker。在master线程中执行业务
逻辑,必要的时候将执行的一部分放到worker中完成后回到master线程或者再次放到其它worker线程中。


派发器可以是多线程访问的:在任意线程都可以派发任务的动作。派发器也可以是单线程访问的,
比如只能在master线程中访问。这个时候只需要提供将任务派发到worker线程的方法。但是这样就
缺乏从worker线程切换到master线程的机制。这个时候要么我们不关心worker线程中的执行结果,要么
可以在master线程中轮询结果,或者直接在worker线程中回调(比较危险)


1.7一个未提及的case
虽然单个业务的每一步只存在于一个线程上,但是毕竟存在多线程访问,如果在worker线程中执行
的时候,任务拥有的资源,业务逻辑的回调接收者已经销毁了呢?因此,在设计任务的时候,应该
具有整个任务上下文的拷贝,这样就不存在资源在别的线程中被释放的问题。而对于回调的接收者
可能销毁的问题,只需要在任务中拥有接收者的弱引用,在回调到master线程时检测一次。


1.8最后需要解决的问题
设计master-worker的问题最终转换为:任务队列设计。向指定线程派发任务时只需要向对应worker
的任务队列加任务。master线程上拥有的任务队列用windows消息来实现(通常master线程是主线程,
拥有消息循环)。worker线程上的任务队列就根据需要实现了。


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有执行的机会。
0 0
原创粉丝点击