并发

来源:互联网 发布:手机屏幕锁屏软件 编辑:程序博客网 时间:2024/04/30 21:14
在多线程的环境,资源必须得到保护,使得它们不会因为一次允许多于一个线程访问而受损。

并发和线程是相互纠缠的问题,选择先学哪个也许很难。本文将先讲讲并发,它将为后面学习线程准备一些该先了解一下的知识。

术语

并发

并发是这样一个状态——许多Task同时启动。当并发被实现得恰恰当当时,它可能被认为是“harmony”。而实现得糟糕时,就成了”chaos“。

在大部分情况中,所说的Task指的都是线程。然而,Task也可以是进程或者纤程。

两者之间的分界通常很清楚,而使用合适的技术才是关键

Contention

确切的说何为Contention?Contention就是当多于一个Task尝试着同时访问那独独一个资源时的情况。

如果你是在大家庭长大的孩子,可能这个比喻能很好的解释它意思。想想家里要是有六个小孩,妈妈把一块小匹萨放在桌上作为晚餐,会发生什么样的情况。那就是Contention的含义。

无论何时,只要多个并发的Task需要用读/写的方式访问数据,对数据的访问都必须得到控制从而保护它的完整性。如果访问没有得到控制,两个或者更多的Task可能会“崩溃”。当其中的一个尝试着要读取变量时,另外一个可能要同时对它进行写入。如果一个Task正在写,而另外一个正在读,那个读的Task可能读取了部分写入的数据从而获得的是损坏了的数据。一般这样的操作是不会立即导致异常的,而只会在这之后给程序带来错误。

Contention问题经常是在低流量的Implementation中不会出现,因而在开发阶段经常是一点问题都没有。所以在开发的阶段应该采用合适的技术和压力测试。否则就有一些像玩Russian Roulette,问题在开发阶段仅仅是偶尔出现但是在部署阶段变成了频繁出现。

资源保护

资源保护是用来阻止由Contention带来的问题的解决办法。资源保护的目的是一次仅让一个Task访问指定的资源。

解决Contention

无论何时,只要多个线程需要以读/写的方式访问数据,对数据的访问都必须得到控制从而保护它的完整性。这可能对于不熟悉线程操作的程序员来说intimidating。然而,大部分服务器不需要全局数据。这些程序一般在启动过程中初始化之后只需要读取数据。只要没有写操作,线程可以没有任何副作用的读
取全局数据。

下面讲的是解决Contention最常用的办法。

只读

最简单的办法是只读。任何简单类型(整数,字符串,内存)以只读的方式访问不需要任何保护。这也可以扩展到诸如TLists等许多复杂类型。只要它们不以读/写方式访问任何全局或者成员变量,类型在只读方式时都是安全的。

此外,资源可以在任何可能的读操作之前被改写。这允许了在读取它的Task启动之前,先初始化资源。

Atomic操作

有一种方法是说如果操作是Atomic,资源不要被保护。Atomic操作是这样一种操作,它太小了以至于不能被计算机处理器分划开来。因为它尺寸小,从而它不会受到Contentiion的影响因为它将由自身执行而且在执行过程中不会有Task的切换。一般情况下,Atomic操作是被编译为一条汇编指令的源代码。

典型的任务诸如读取或者写入一个整型或者布尔型变量被认为是Atomic操作,因为它们被编译为一条Move指令。然而我推荐你绝对不要依赖原子操作,因为某些情况下甚至写入一个整型或者布尔型变量都能包括多于一个的动作,这要看数据首先是从哪儿读来的。此外,这还依赖于编译器内部的奥秘,而这可能会在不告知你的情况下做出改变。依赖源代码级的Atomic操作将产生未来会有问题的代码而且可能在多处理器的机器或者别的操作系统上行为非常不同。

我曾经见过一个铁打不动的Atomic操作。然而一个非常prominent的未来事件证明了我的观点,那就是.net。你的代码首先编译为IL,然而再编译为机器码,可能还是在不同厂商的不同平台上,你还能确信你的代码最终还是Atomic操作吗?

选择最终还是要看你自己,当然有许多声音围绕着对Atomic操作的偏爱和反对。在大部分情况下,依赖原子操作仅仅节省了几毫秒,以及几字节的代码。我强烈推荐不要去使用Atomic操作,因为它们带来的好处如此至少而liabilities如此之巨大。把所有操作都当作非Atomic操作来对待。

操作系统的支持

许多操作系统对非常基本的线程安全的操作提供了支持。

Windows支持一套称为Interlocked Function的函数。这些函数的用处非常有限,而且仅仅包括简单的对整数的操作,诸如步增,步减,加,Swap以及Swap-Compare。

函数的数目和Windows的版本有关,而且可能在低版本的Windows上发生死锁。在大部分应用程序中,它们提供的性能上的好处非常少。

因为综合这些,用处有限,不断变化的支持,可怜的性能优势因素,建议你用Indy的线程安全的同等物来替代。

Windows还包括对特殊的IPC(进程间通信)对象的支持,这些对象在Delphi有经过包装的类。这些对象和IPC一样对线程操作极端有用。

显式保护

显式保护包括每个Task都知道一个资源受到了保护而且在访问这个资源之前采取了显式的防御步骤。一般这样的代码式在被多个Task并发执行的函数之中,或者被封装到了一个被许多不同位置调用的函数之中作为一个线程安全的封装。

显示保护一般要利用资源保护对象。简单来说,资源保护对象把对资源的访问限制为了一次一Task。资源保护对象并没有实际限制对资源的访问,如果它做到了,它可能必须要知道每一个和所有的资源类型的细节。它就像红绿灯,而代码要遵守它并给它提供输入。每个资源保护对象用不同的机理,不同的输入,以及不同程度的额外负担实现了不同种类的红绿灯。这使得能够选择不同的资源保护对象来更好的适应不同类型的资源以及不同的场合。

资源保护对象以不同形式存在,下面就来逐个介绍。

Critical Section

Critical Section可以用来控制对全局资源的访问。Critical Section是轻量级的并且在VCL中实现于TCriticalSection之中。简单来说,Critical Section使得多线程程序中的一个线程能够暂时阻塞所有其他线程尝试使用同一个Critical Section。Critical Section就像红绿灯,只当前面的路没有任何车辆时才变绿。Critical Section可以用来确保一次只有一个线程正在执行那一块代码。因此,受到Critical Section保护的那块代码应当尽可能的小因为如果使用不当的话它们可能严重影响性能。所以,每块代码都应当使用它们自己的TCriticalSection,而不是重用全程序共享的TCriticalSection。

要进入Critical Section,使用Enter方法,而Leave方法是用来退出Critical Section的。TCriticalSection还分别有Acquire和Release方法来做与Enter和Leave完全一样的事情。

假设有一个服务器需要记录有关登陆了的客户端的信息,并且要在主线程中显示这些信息。一个可能的选择是使用Synchronize。然而使用这个方法在同时有许多客户登陆时会对连接线程产生性能上的负面影响。取决于服务器的需要,一个更好的选项可能是记录下信息并且让主线程用Timer来读取这些信息。下面的代码是一个使用了Critical Section的这种技术的例子。

var
GLogCS: TCriticalSection;
GUserLog: TStringList;
procedure TformMain.IdTCPServer1Connect(AThread: TIdPeerThread);
var
s: string;
begin
// Username
s := ReadLn;
GLogCS.Enter; try
GUserLog.Add('User logged in: ' + s);
finally GLogCS.Leave; end;
end;
procedure TformMain.Timer1Timer(Sender: TObject);
begin
GLogCS.Enter; try
listbox1.Items.AddStrings(GUserLog);
GUserLog.Clear;
finally GLogCS.Leave; end;
end;
initialization
GLogCS := TCriticalSection.Create;
GUserLog := TStringList.Create;
finalization
FreeAndNil(GUserLog);
FreeAndNil(GLogCS);
end.

在Connect事件中,用户名在进入Critical Section之前被读入一个临时变量。这样做是为了避免由阻塞Critical Section带来的对客户端可能的减慢。这使得网络通信能够在进入Critical Section之前被执行。为了使得性能最佳,Critical Section中的代码应该越少越好。

Timer1Timer事件在主线程中被主窗体上的一个计时器触发。计时器的时间间隔可以被缩短来达到更新更加频繁的目的,但是可能会降低接受连接的速度。如果日志的功能被扩展到了服务器的其他地方,不仅仅是记录用户的连接,这更加剧了产生瓶颈的可能。更短促的时间的间隔,更新用户界面所需的时间就更少。然而许多服务器根本就没有用户界面,即便是有也一般是第二位的,比服务客户端的优先级低得多,从而这是一个很好接受的权衡。

TCritical Section位于SyncObjs Unit中。SyncObjs Unit没有包括在Delphi 4的标准版中。如果你正在使用Delphi 4的标准版,在Indy的网站上有一个SyncObjs.pas,它虽然没有实现Borland的SyncObjs.pas中的所有内容,但是实现了TCriticalSection类。

TMultiReadExclusiveWriteSynchronizer(TMREWS)

在前面的例子中,TCriticalSection被用来保护对全局数据的访问。在那些情况中,全局数据只是一直被更新。然而,如果全局数据有时被只读的访问,使用TMultiReadExclusiveWriteSynchronizer可能产生更加有效率的源代码。TMultiReadExclusiveWriteSynchronizer是一个冗长而又难读的类。因此它将被简单的称作TMREWS。

使用TMREWS的优势是它允许多线程的并发读取,同时又与Critical Section一样允许读的时候只有一个线程访问。劣势是TMREWS用起来要费更高的代价。

不再是Enter/Acquire和Leave/Release,TMREWS有方法BeginRead,EndRead,BeginWrite和EndWrite。

关于TMREWS的特别说明

在Delphi 6之前,TMultiReadExclusiveWriteSynchronizer在从一个read lock变为一个write lock时可能导致死锁。因此你绝对不应该使用把read lock变成write lock的特性,即便文档上说是可以这么做的。

如果你需要使用这个功能,有一个折衷的办法。那就是先释放read lock然后再获得write lock。然而一旦你获得了write lock,然后你必须再次检查首先迫使你要用一个write lock的条件。如果它仍然存在,执行需要做的事情,否则立刻释放write lock。

在使用Delphi 6时,TMultiReadExclusiveWriteSynchronizer还是有特别要考虑的地方。所有版本的TMultiReadExclusiveWriteSynchronizer,包括update pack 1和update pack 2中的,都有可能导致死锁的严重问题。没有已知的解决办法。Borland知道这个问题的存在,并且已经发布了非官方的补丁并且可能会发布官方的补丁。

Kylix中的TMREWS

Kylix 1和Kylix 2中的TMultiReadExclusiveWriteSynchronizer内部是用Critical Section实现的,并且不会比使用Critical Section有任何优势。然而,它被包括进来是为了代码能够同时用于Linux和Windows。在Kylix的未来版本中,TMultiReadExclusiveWriteSynchronizer可能升级为如它在Windows下的表现一样。

在Critical Section和TMREWS之间选择

因为TMREWS已经被问题缠上了,我的建议很简单,就是避免使用它。如果你决定使用它,你应当确信它确实是更好的选择而且你已经获得了一个不再产生死锁行为的打过补丁的版本。

在大部分情况下,对TCriticalSection的恰当应用可以产生几乎一样快的效果,而且在某些情况下是更快。学会在必要的地方优化你的TCriticalSection,因为对TCriticalSection的不恰当使用将对性能产生严重的负面影响。

任何资源保护问题的关键都是使用多个资源Controller,并且让加锁区域尽可能的小。当能做到这些时,总是应当使用Critical Section,因为它是轻量的而且比TMREWS更快。总的来说,除非你能够明显的判断TMREWS的用处,总是使用Critical Section。

TMREWS类在以下条件都满足时性能更好:

1、访问包括读和写
2、读是主要的
3、加锁的时间必须被扩展开来维护,不能被分解为更小的块。
4、TMREWS类被合适地打上了补丁,并且已知工作正常了。

性能比较

如前面提到过地,Critical Section更加轻量因而速度更快。Critical Section是由操作系统实现的。操作系统是使用非常快速和精简的汇编代码来实现它们的。

TMREWS类更加复杂因而会带来更多的额外负担。它必须管理请求者的列表来合理地管理双状态的加锁机制。

为了展示这些区别,创建了一个名为ConcurrencySpeed.dpr的示例项目。它执行了三个如下的简单的肚量:
1、TCriticalSection – Enter 和 Leave
2、TMREWS – BeginRead 和 EndRead
3、TMREWS – BeginWrite 和 EndWrite

测试是在一个计数的循环中运行它们一定次数。为了测试的目的,缺省是100,000次。在我的测试中,产生了如下的结果(毫秒计):
TCriticalSection:20
TMREWS(Read Lock):150
TMREWS(Write Lock):401

自然这些测量是和机器有关的。然而,这儿它们之间的差别才是重要的,而不是确切的数字。可以明显的看出TMREWS的read lock比Critical Section慢7.5倍,而write lock要慢20倍。

还应该注意此时Critical Section只有一种结果,而TMREWS的性能在并发使用时还会下降。这儿执行的测试只是简单的在一个循环之中,没有其他的请求者在进行请求或者有已经存在锁需要TMREWS来对付。在实际的情况之中,TMREWS可能比这儿显示的数字还要慢。

Mutex

Mutex和Critical Section的功能几乎一致。Mutex的不同之处在于它是一个有更多功能的增强版Critical Section,当然额外负担也更多。

Mutex有像能够命名,赋予安全属性,进程间访问这样的额外功能。

Mutex可以在线程之间使用,但是很少这么用。Mutex被设计用于进程间通信,而且一般也是这么用的。

Semaphore

Semaphore类似于Mutex,但是不仅仅是一个Entrant,它允许多个Entrant。Entrant的数量可以在Semaphore创建时指定。

假想一下Mutex是一个正在守卫银行现钞提款机(ATM)的安全警卫。一次仅一人可以使用它,但是安全警卫正在保卫机器不让一队人同时使用它。

如果安装了4台ATM,Semaphore可能就能派上用场。在这种情况下,安全警卫可能允许一次4人进入并使用ATM,但是一次不能多于4人。

Event

Event是用于线程或者进程间来通知某事已经发生的信号。Event可以在某事被完成或者需要干预时用来通知其他Task。

线程安全的类

线程安全的类是经过特别设计用来保护特定类型资源的类。每个线程安全的类都实现了一种类型的资源,并且对资源是什么和如何用它都有良好的认知。

线程安全的类可以简单如线程安全的整数,也可以复杂如线程安全的数据库。线程安全的类内部使用线程安全对象来完成它们的功能。

Compartmentalization

Compartmentalization是分离数据并把它赋给单一Task使用的过程。对于服务器来说,Compartmentalization经常是自然而然的,因为每个客户端都能由专门的线程来处理。

当Compartmentalization不是天然的时,应当考量考量,看看是不是能够这么做。Compartmentalization经常可以通过拷贝全局数据,对数据进行处理,然后把结果返回给全局区域来达到。通过使用Compartmentalization,数据的加锁仅仅发生在初始化和任务结束或者批量更新之时。