在开始之前,让我们先来回顾几个用于各种操作系统的术语:当不区分 Windows 2000 和 Windows XP 时,使用“Windows”。当要进行区分时,使用“Windows 2000”或者“Windows XP”。
本文中,我要集中探讨实现进程间和线程间同步的许多方法。通过同步,可以进行受控访问。例如,如果两个进程(或者线程)希望更新同一个共享内存计数器,那么这个计数器必须由这两个进程分别作原子更新。为实现这一点,进程必须控制计数器足够长的时间以便从内存中读取计数器,对它进行增量操作,然后将它返回内存。在大多数计算机上,这个操作由多条机器指令构成。在多指令更新过程中,为使操作系统避免发生意外的上下文环境切换,可以使用进程同步原语。
进程同步需要相互协作。每个进程必须同意遵守同步的规则。一旦达成协议,则有许多机制可用于同步。一些机制只适合于线程,一些机制适合于进程,还有一些甚至运用于网络上进行进程或线程间的同步。本文将研究专为同步而设计的计算机内的原语:
- 信号量(Semaphores)
- 互斥锁(Mutexes)
- 临界段(Critical sections)
让我们从信号量开始。它可以由不同的线程或者不同的进程使用。信号量作为计数器来实现。为获得独占控制,线程必须“获得”信号量。一个“获得”转化成从信号量值中减 1。如果信号量的当前值为 0,那么进程就阻塞,直到该值减 1 后的结果大于或等于 0 为止。操作系统保证在其它线程或者进程试图进行相同的减法和 0 测试操作中,这个操作是一个原子操作。因此,为获取信号量,进程就尝试减 1 操作。如果这个操作所得到的结果是个负值,进程就阻塞。
在 Windows 上,信号量操作是 ReleaseSemaphore()
和 WaitForSingleObject()
。ReleaseSemaphore() 对应于给信号量的值加 1。 WaitForSingleObject()
对应于给信号量的值减 1。
在 Linux 上,有两类信号量。第一类是由 semget/semop/semctl API 定义的信号量的 SVR4(System V Release 4)版本。第二类是由 sem_init/sem_wait/sem_post/interfaces 定义的 POSIX 接口。 它们具有相同的功能,但接口不同。我写了一个程序,用来测试这些接口并对它们计时。这是一个单线程程序。(实际上,存在两个线程,但同步原语都以单线程方式执行。)
在我们查看代码之前,先扯开来谈些别的。编写计时循环涉及到猜测适当最大值的循环计数器来产生足够多的执行次数。在所讨论接口的基本计时已知之前,通过计时循环挑选合适的循环次数是个纯粹猜测的工作。通过在一段固定的时间内运行循环,本文程序中消除了这个问题。不是计算执行一百万次操作需要的时间,而是在一段固定的时间内,对执行的次数进行计数。 我选择 2 秒作为缺省值;本文中的所有计时都指两秒的执行周期。
计时器循环在一段固定的时间后简单地将单个全局标志的值更改为 0。当全局标志的值为 1 时,实际的执行循环对执行的次数进行计数。当全局标志的值变为 0 时,循环终止并且提交计时报告。 为实现计时器, starttimedtest()
例程创建了一个线程,它只负责休眠几个纳秒后,将标志值更改为 0,并退出。计时测试实用程序显示如下。
volatile int run_count = 0; int startTimedTest(volatile int *flg) { static int first = 1; if(first) { #ifdef _WIN32 InitializeCriticalSection(&lock_run_count); #else (void)pthread_mutex_init(&lock_run_count,NULL); #endif first = 0; } run_count = 0; #ifdef _WIN32 th1 = CreateThread(NULL, 4096,timerloop,(char *)flg,NULL,&timerId); if(th1 == NULL) { printf("CreateThread FAILED: err=%d\n",errno); return 1; } #else # define DEC (void *(*)(void *)) if(pthread_create(&tA,NULL,DEC timerloop,(void *)&timedtestflag)) { printf("pthread_create FAILED: err=%d\n", errno); return 1; } #endif while(run_count != 1) YIELD; return 0; } unsigned long WINDEC timerloop(void *v) { int *flg = (int *)v; LOCK(&lock_run_count); run_count++; UNLOCK(&lock_run_count); *flg = 1; SLEEP(nseconds); *flg = 0; return 0; } void endTimedTest() { #ifdef _WIN32 (void)WaitForSingleObject(&th1, INFINITE); #else if(pthread_join(tA, (void **)&threadreturn)) { printf("pthread_join FAILED: err=%d\n",errno); return; } #endif }
注意 starttimedtest()
的 volatile int *flg
自变量。为了解释 C 或者 C++ 中的关键字 volatile
或者 const
,只需从右到左阅读声明。传递了一个 volatile 的整数指针作为 starttimedtest()
其参数。“Volatile”指的是不允许编译器优化对 *flg
表达式指定内存的间接引用。
使用计时实用程序以及 timedtest 接口,Windows 信号量计时循环程序显示如下。
semaA = CreateSemaphore(NULL, 0, 1, "semaABC"); if(semaA == NULL) { printf("CreateSemaphore \"semaABC\" failed ERROR=%d\n", GetLastError()); return 1; } count = 0; timedtestflag = 0; if(startTimedTest(&timedtestflag)) return 1; tstart(); while(timedtestflag) { count++; // // Increment // if(!ReleaseSemaphore(semaA,1,0)) { printf("ReleaseSema failed: error=%d\n",GetLastError()); return 1; } // // Decrement // if(WaitForSingleObject(semaA, INFINITE) == WAIT_FAILED) { printf("Wait in ALREADY_EXISTS child failed err=%d\n", GetLastError()); return 1; } } tend(); endTimedTest(); t = tval();
计时测试实用程序为指定时间内运行所有我们的测试提供了一个简单的框架。只需要启动计时器,对执行的次数进行计数,直到标志值回到 0 为止。这些实用程序本可以与作为工作线程的附加线程一起编写。那样有利于向 starttimedtest()
例程(真正用来计时的函数)提供一个自变量。我选择了在另一个线程中执行计时器,在只用于测试代码可视性的主线程中加入待测代码。 信号量的文档存放在 Windows Platform SDK(信号量对象)和 Linux 帮助页(输入 man semop
可找到有关 System V 信号量的帮助页面,输入 man sem_init
可找到有关 POSIX 信号量的页面)中。
互斥锁是那些值只能为 0 或 1 的信号量。一个互斥锁可以作为一个入口,每次只让一个线程或进程访问一个资源。互斥锁表示 互相 排斥 线程的访问;在给定的时间内,只有一个线程可以“拥有”一个互斥锁。互斥锁与信号量很相似并且它们可以在支持信号量的同一操作系统代码下实现。事实上,从下面的计时评测中可以看出,可能 Windows 正好是这样做的。UNIX 和 Linux 中的信号量先于 POSIX 线程支持的互斥锁出现。
互斥锁的文档存放于 Windows Platform SDK(互斥锁对象)和 Linux 帮助页( man pthread_mutex_lock
)中。
最后,Windows 有一种称为临界段(Critical Section)的方法。 临界段为相互排斥提供了最低开销机制,但是只能由单个进程中的线程使用。它们的行为与互斥锁很相似;但开销却相当少。它们的文档存放于 Platform SDK(临界段对象)中。
我写了一个单个程序,用来练习加锁/解锁接口。它基于前面提到的计时测试实用程序。在 Windows 2000 和 Windows XP 上运行 sync6.cpp 的结果如图 1 所示。
图 2 显示了在 Red Hat 7.1 Linux(其内核版本是 Linux 2.4.2)上运行的结果。
图 1 和图 2 出自表 1 和表 2,两表明确显示了 sync6.cpp 程序评测的时间。
接口
Win2K
WinXP
Mutex
2.629
2.191
Sema
2.555
2.149
CriticalSection
0.046
0.129
接口
Linux 2.4.2
SVR5_Semaphores
1.828
POSIX_Semaphores
0.487
pthread_mutex
0.262
表 1 Windows 同步原语(usec/call-pair)
表 2 Linux 同步原语(usec/call-pair)
在第一张图上可以看到,Windows XP 信号量和互斥锁的执行次数改进为只有 Windows 2000 的 83%。CriticalSection API 看起来在执行时间上提高了 280%。CriticalSection API 应该取决于硬件内存互锁指令,所以为什么 Windows XP 中的 CriticalSection API 比 Windows 2000 中的慢得多,这一点不是很清楚。
Linux 接口显示了传统 SVR5 信号量是最慢的执行者,而 pthread 互斥锁是最快的执行者。尽管 Windows XP CriticalSection API 的速度降低了下来,但它们仍然比 Linux pthread 互斥锁快 2 倍。
也可能使用其它机制来处理进程或者线程同步。例如,块通信信道可以用于同步。块信道包括管道、套接字、串行线和红外信道。在这个星期的测试中,最长的时间是 Windows 2000 互斥锁,为 2.62 微秒/调用对。如果使用 Windows 上的命名管道,那么我们可以希望得到的最好的一点是移动最少数据量所涉及的开销。那将是一个单字节。在我 以前关于管道的文章中公布的电子表格上可以得出,使用一个 1 字节大小的块产生的结果是每秒钟移动 144,000 个字节。那相当于每秒写/读 144,000 次,或者 6.944 微秒/调用对。 这要比这周测试中所记录的对 Windows 上任何原语的测试结果都要糟糕。除非有其它原因,否则将管道用于同步机制是个很差的性能选择。
Linux 管道的速度比 Windows 管道的速度快。Linux 上移动一个一字节大小的块,其结果是每秒 500,000 字节或者每秒 500,000 个调用对或者每个调用对 2.0 微秒。管道几乎与 SVR4 信号量一样好(1.828 微秒),但是比 POSIX 信号量(0.487 微秒)或者 pthread 互斥对象(0.262 微秒)差了许多。除非要考虑其它事项,否则管道对于 Linux 上的进程同步也不是一个好的选择。
我写了一个程序 sync6.cpp
来演示 Windows 和 Linux 上各种进程和线程同步原语的用途并评测了它们的性能。 我们发现其中最快的原语是 Windows 临界段。我们还看到 Windows 2000 与 Windows XP 的信号量性能一起得到了改进,但是 Windows XP 的 CriticalSection API 减慢了。
在我 以前关于管道的文章中有许多假设。许多读者对此提供了帮助,发表了自己的评论。其中提出的最值得注意的问题,也是我的错误处,即,使用通过管道的任何内存。 当我开始评测代码路径开销以及公布每秒钟的字节数时,我的想法就偏了。那的确是个错误。
因此,作为本文的附录,我公布了上月 pipespeed3t.cpp 程序的修正版。读者提出的所有问题都在这个版本中解决了。图 3 显示了在 Linux、Windows 2000 和 Window XP 上运行 pipespeed3t 产生的结果。对于每个操作系统,一种运行不通过访问内存来完成,另一种运行涉及到读和写缓冲区。这种内存访问由对每次写入不同数据的每个写缓冲区进行 memset()
操作以及对每个读缓冲区进行 memcpy
操作组成。 对结果绘图,其中,红色代表 Windows XP,绿色代表 Windows 2000,蓝色代表 Linux。 粗线代表访问内存评测,细线代表未访问内存评测。
从图中可以看出,所有曲线的形状几乎相同,相关的性能未改变。对于 Windows 2000 和 Linux,访问内存的管道的传输速度下降到不访问内存的约 60%。对于 Windows XP,是否访问内存看起来没有发生很大的区别。
曾有少数关于比较“苹果和桔子”(未命名管道与命名管道)的评论。表面上看,它们似乎完全不同。以前我曾比较了这两种管道,但是忽略了在本专栏文章中将它指出。因此,为满足读者的要求以及为了证实我的记忆力,我添加了几个选项,在 Linux( mknod()
API)上使用命名管道,在 Windows 上使用未命名管道( CreatePipe()
API)。
因此,我比较了 Linux 未命名管道和 Linux 命名管道。 这两种接口的特征是: pipe()
系统调用用于未命名管道, mknod()
系统调用用于命名管道。 图 4 显示了结果。这两种方法看起来差别不大。
在 Windows 2000 上,运行了对缓冲区空间大小的搜索并将之与 CreateNamedPipe()
的结果相比较。图 5 显示了比较结果。图中带“X”的曲线是 CreateNamedPipe()
的结果。可以看到通过在 CreatePipe()
API 内设置缓冲区的大小可获得管道速度的一些改进。
在 Windows XP 上运行了相同的测试,结果显示在图 6 中。这里的黑色菱形块显示了 CreateNamedPipe()
值。Windows 2000 在管道中传输数据要比 Windows XP 快约三倍。
从这些图表中我们看到,Windows 2000 和 Windows XP 上的命名管道可能基于相同的底层技术。Windows 2000 在 CreatePipe()
API 调用上显示了一些改进,即仔细地进行缓冲区大小的选择。Pipespeed3t 提供了这些图表的评测程序。源代码可在 参考资料一节上找到。
- 您可以参阅本文在 developerWorks 全球站点上的 英文原文.
- 单击本文顶部或底部的 讨论,参与关于本文的 论坛。
- 阅读我以前 developerWorks中关于 管道的文章。
- 本文中用到的代码文件:
- 阅读 developerWorks上 Ed 的其它“运行时”专栏文章:
- 阅读 developerWorks上的相关文章:
- 请浏览 developerWorks上 更多 Linux 参考资料
- 请浏览 developerWorks上 更多开放源代码参考资料。
Edward Bradford 博士现在为 IBM Software Group 管理 Microsoft Premier Support,并且每周还为 “Linux 和 Windows 2000 软件开发者”撰写新闻简报。可以通过 egb@us.ibm.com与他联系。
- 本文固定链接: http://www.wy182000.com/2011/01/20/运行时-使进程和线程同步/
- 转载请注明: wy182000 于 Studio 发表