在Unix上编程采用多线程还是多进程的争执由来已久,这种争执最常见到在C/S通讯中服务端并发技术 的选型上,比如WEB服务器技术中,Apache是采用多进程的(perfork模式,每客户连接对应一个进程,每进程中只存在唯一一个执行线程),Java的Web容器Tomcat、Websphere等都是多线程的(每客户连接对应一个线程,所有线程都在一个进程中)。
从Unix发展历史看,伴随着Unix的诞生进程就出现了,而线程很晚才被系统支持,例如Linux直到内核2.6,才支持符合Posix规范的NPTL线程库。进程和线程的特点,也就是各自的优缺点如下:
进程优点:编程、调试简单,可靠性较高。
进程缺点:创建、销毁、切换速度慢,内存、资源占用大。
线程优点:创建、销毁、切换速度快,内存、资源占用小。
线程缺点:编程、调试复杂,可靠性较差。
上面的对比可以归结为一句话:“线程快而进程可靠性高”。线程有个别名叫“轻量级进程”,在有的书籍资料上介绍线程可以十倍、百倍的效率快于进程; 而进程之间不共享数据,没有锁问题,结构简单,一个进程崩溃不像线程那样影响全局,因此比较可靠。我相信这个观点可以被大部分人所接受,因为和我们所接受 的知识概念是相符的。
在写这篇文章前,我也属于这“大部分人”,这两年在用C语言编写的几个C/S通讯程序中,因时间紧总是采用多进程并发技术,而且是比较简单的现场为 每客户fork()一个进程,当时总是担心并发量增大时负荷能否承受,盘算着等时间充裕了将它改为多线程形式,或者改为预先创建进程的形式,直到最近在网 上看到了一篇论文《Linux系统下多线程与多进程性能分析》作者“周丽 焦程波 兰巨龙”,才认真思考这个问题,我自己也做了实验,结论和论文作者的相似,但对大部分人可以说是颠覆性的。
下面是得出结论的实验步骤和过程,结论究竟是怎样的? 感兴趣就一起看看吧。
实验代码使用周丽论文中的代码样例,我做了少量修改,值得注意的是这样的区别:
论文实验和我的实验时间不同,论文所处的年代linux内核是2.4,我的实验linux内核是2.6,2.6使用的线程库是NPTL,2.4使用的是老的Linux线程库(用进程模拟线程的那个LinuxThread)。
论文实验和我用的机器不同,论文描述了使用的环境:单 cpu 机器基本配置为:celeron 2.0 GZ, 256M, Linux 9.2,内核 2.4.8。我的环境是我的工作本本:单cpu单核celeron(R) M 1.5 GZ,1.5G内存,ubuntu10.04 desktop,内核2.6.32。
进程实验代码(fork.c):
进程实验代码(thread.c):
两段程序做的事情是一样的,都是创建“若干”个进程/线程,每个创建出的进程/线程打印“若干”条“hello linux”字符串到控制台和日志文件,两个“若干”由两个宏 P_NUMBER和COUNT分别定义,程序编译指令如下:
diaoyf@ali:~/tmp1$ gcc -o fork fork.c
diaoyf@ali:~/tmp1$ gcc -lpthread -o thread thread.c
实验通过time指令执行两个程序,抄录time输出的挂钟时间(real时间):
time ./fork
time ./thread
每批次的实验通过改动宏 P_NUMBER和COUNT来调整进程/线程数量和打印次数,每批次测试五轮,得到的结果如下:
一、重复周丽论文实验步骤
第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
---|---|---|---|---|---|---|
多进程 | 0m1.277s | 0m1.175s | 0m1.227s | 0m1.245s | 0m1.228s | 0m1.230s |
多线程 | 0m1.150s | 0m1.192s | 0m1.095s | 0m1.128s | 0m1.177s | 0m1.148s |
进程线程数:255 / 打印次数:100
第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
---|---|---|---|---|---|---|
多进程 | 0m6.341s | 0m6.121s | 0m5.966s | 0m6.005s | 0m6.143s | 0m6.115s |
多线程 | 0m6.082s | 0m6.144s | 0m6.026s | 0m5.979s | 0m6.012s | 0m6.048s |
进程线程数:255 / 打印次数:500
第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
---|---|---|---|---|---|---|
多进程 | 0m12.155s | 0m12.057s | 0m12.433s | 0m12.327s | 0m11.986s | 0m12.184s |
多线程 | 0m12.241s | 0m11.956s | 0m11.829s | 0m12.103s | 0m11.928s | 0m12.011s |
进程线程数:255 / 打印次数:1000
第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
---|---|---|---|---|---|---|
多进程 | 1m2.182s | 1m2.635s | 1m2.683s | 1m2.751s | 1m2.694s | 1m2.589s |
多线程 | 1m2.622s | 1m2.384s | 1m2.442s | 1m2.458s | 1m3.263s | 1m2.614s |
进程线程数:255 / 打印次数:5000
本轮实验是为了和周丽论文作对比,因此将进程/线程数量限制在255个,论文也是测试了255个进程/线程分别进行10 次,50 次,100 次,200 次……900 次打印的用时,论文得出的结果是:任务量较大时,多进程比多线程效率高;而完成的任务量较小时,多线程比多进程要快,重复打印 600 次时,多进程与多线程所耗费的时间相同。
虽然我的实验直到5000打印次数时,多进程才开始领先,但考虑到使用的是NPTL线程库的缘故,从而可以证实了论文的观点。从我的实验数据看,多线程和多进程两组数据非常接近,考虑到数据的提取具有瞬间性,因此可以认为他们的速度是相同的。
当前的网络环境中,我们更看中高并发、高负荷下的性能,纵观前面的实验步骤,最长的实验周期不过1分钟多一点,因此下面的实验将向两个方向延伸,第一,增加并发数量,第二,增加每进程/线程的工作强度。
二、增加并发数量的实验
下面的实验打印次数不变,而进程/线程数量逐渐增加。在实验过程中多线程程序在后三组(线程数500,800,1000)的测试中都出现了“段错误”,出现错误的原因和线程栈的大小有关。
实验中的计算机CPU是32位的赛扬,寻址最大范围是4GB(2的32次方),Linux是按照3GB/1GB的方式来分配内存,其中1GB属于所 有进程共享的内核空间,3GB属于用户空间(进程虚拟内存空间),对于进程而言只有一个栈,这个栈可以用尽这3GB空间(计算时需要排除程序文本、数据、 共享库等占用的空间),所以它的大小通常不是问题。但对线程而言每个线程有一个线程栈,这3GB空间会被所有线程栈摊分,线程数量太多时,线程栈累计的大 小将超过进程虚拟内存空间大小,这就是实验中出现的“段错误”的原因。
Linux2.6的默认线程栈大小是8M,可以通过 ulimit -s 命令查看或修改,我们可以计算出线程数的最大上线: (1024*1024*1024*3) / (1024*1024*8) = 384,实际数字应该略小与384,因为还要计算程序文本、数据、共享库等占用的空间。在当今的稍显繁忙的WEB服务器上,突破384的并发访问并不是稀 罕的事情,要继续下面的实验需要将默认线程栈的大小减小,但这样做有一定的风险,比如线程中的函数分配了大量的自动变量或者函数涉及很深的栈帧(典型的是 递归调用),线程栈就可能不够用了。可以配合使用POSIX.1规定的两个线程属性guardsize和stackaddr来解决线程栈溢出问题,guardsize控制着线程栈末尾之后的一篇内存区域,一旦线程栈在使用中溢出并到达了这片内存,程序可以捕获系统内核发出的告警信号,然后使用malloc获取另外的内存,并通过stackaddr改变线程栈的位置,以获得额外的栈空间,这个动态扩展栈空间办法需要手工编程,而且非常麻烦。
有两种方法可以改变线程栈的大小,使用 ulimit -s命令改变系统默认线程栈的大小,或者在代码中创建线程时通过pthread_attr_setstacksize函数改变栈尺寸,在实验中使用的是第一 种,在程序运行前先执行ulimit指令将默认线程栈大小改为1M:
diaoyf@ali:~/tmp1$ ulimit -s 1024
diaoyf@ali:~/tmp1$ time ./thread
第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
---|---|---|---|---|---|---|
多进程 | 0m4.958s | 0m5.032s | 0m5.181s | 0m4.951s | 0m5.032s | 0m5.031s |
多线程 | 0m4.994s | 0m5.040s | 0m5.071s | 0m5.113s | 0m5.079s | 0m5.059s |
进程线程数:100 / 打印次数:1000
第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
---|---|---|---|---|---|---|
多进程 | 0m12.155s | 0m12.057s | 0m12.433s | 0m12.327s | 0m11.986s | 0m12.184s |
多线程 | 0m12.241s | 0m11.956s | 0m11.829s | 0m12.103s | 0m11.928s | 0m12.011s |
进程线程数:255 / 打印次数:1000 (这里使用了第一次的实验数据)
第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
---|---|---|---|---|---|---|
多进程 | 0m17.686s | 0m17.569s | 0m17.609s | 0m17.663s | 0m17.784s | 0m17.662s |
多线程 | 0m17.694s | 0m17.976s | 0m17.884s | 0m17.785s | 0m18.261s | 0m17.920s |
进程线程数:350 / 打印次数:1000
第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
---|---|---|---|---|---|---|
多进程 | 0m23.638s | 0m23.543s | 0m24.135s | 0m23.981s | 0m23.507s | 0m23.761s |
多线程 | 0m23.634s | 0m23.326s | 0m23.408s | 0m23.294s | 0m23.980s | 0m23.528s |
进程线程数:500 / 打印次数:1000 (线程栈大小更改为1M)
第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
---|---|---|---|---|---|---|
多进程 | 0m38.517s | 0m38.133s | 0m38.872s | 0m37.971s | 0m38.005s | 0m38.230s |
多线程 | 0m38.011s | 0m38.049s | 0m37.635s | 0m38.219s | 0m37.861s | 0m37.995s |
进程线程数:800 / 打印次数:1000 (线程栈大小更改为1M)
第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
---|---|---|---|---|---|---|
多进程 | 0m48.157s | 0m47.921s | 0m48.124s | 0m48.081s | 0m48.126s | 0m48.082s |
多线程 | 0m47.513s | 0m47.914s | 0m48.073s | 0m47.920s | 0m48.652s | 0m48.014s |
进程线程数:1000 / 打印次数:1000 (线程栈大小更改为1M)
出现了线程栈的问题,让我特别关心Java线程是怎样处理的,因此用Java语言写了同样的实验程序,Java程序加载虚拟机环境比较耗时,所以没 有用time提取测试时间,而直接将测时写入代码。对Linux上的C编程不熟悉的Java程序员也可以用这个程序去对比理解上面的C语言试验程序。
第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
---|---|---|---|---|---|---|
Java | 65664 毫秒 | 66269 毫秒 | 65546 毫秒 | 65931 毫秒 | 66540 毫秒 | 65990 毫秒 |
线程数:1000 / 打印次数:1000
Java程序比C程序慢一些在情理之中,但Java程序并没有出现线程栈问题,5次测试都平稳完成,可以用下面的ps指令获得java进程中线程的数量:
diaoyf@ali:~$ ps -eLf | grep MyThread | wc -l
1010
用ps测试线程数在1010上维持了很长时间,多出的10个线程应该是jvm内部的管理线程,比如用于GC。我不知道Java创建线程时默认栈的大 小是多少,很多资料说法不统一,于是下载了Java的源码jdk-6u21-fcs-src-b07-jrl-17_jul_2010.jar(实验环境 安装的是 SUN jdk 1.6.0_20-b02),但没能从中找到需要的信息。对于jvm的运行,java提供了控制参数,因此再次测试时,通过下面的参数将Java线程栈大 小定义在8192k,和Linux的默认大小一致:
diaoyf@ali:~/tmp1$ java -Xss8192k MyThread
出乎意料的是并没有出现想象中的异常,但用ps侦测线程数最高到达337,我判断程序在创建线程时在栈到达可用内存的上线时就停止继续创建了,程序 运行的时间远小于估计值也证明了这个判断。程序虽然没有抛出异常,但运行的并不正常,另一个问题是最后并没有打印出“用时 xxx毫秒”信息。
这次测试更加深了我的一个长期的猜测:Java的Web容器不稳定。因为我是多年编写B/S的Java程序员,WEB服务不稳定常常挂掉也是司空见 惯的,除了自己或项目组成员水平不高,代码编写太烂的原因之外,我一直猜测还有更深层的原因,如果就是线程原因的话,这颠覆性可比本篇文章的多进程性能颠 覆性要大得多,想想世界上有多少Tomcat、Jboss、Websphere、weblogic在跑着,嘿嘿。
这次测试还打破了以前的一个说法:单CPU上并发超过6、7百,线程或进程间的切换就会占用大量CPU时间,造成服务器效率会急剧下降。但从上面的实验来看,进程/线程数到1000时(这差不多是非常繁忙的WEB服务器了),仍具有很好的线性。
三、增加每进程/线程的工作强度的实验
这次将程序打印数据增大,原来打印字符串为:
现在修改为每次打印256个字节数据:
第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
---|---|---|---|---|---|---|
多进程 | 0m28.149s | 0m27.993s | 0m28.094s | 0m27.657s | 0m28.016s | 0m27.982s |
多线程 | 0m28.171s | 0m27.764s | 0m27.865s | 0m28.041s | 0m27.780s | 0m27.924s |
进程线程数:255 / 打印次数:100
第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
---|---|---|---|---|---|---|
多进程 | 2m20.357s | 2m19.740s | 2m19.965s | 2m19.788s | 2m19.796s | 2m19.929s |
多线程 | 2m20.061s | 2m20.462s | 2m19.789s | 2m19.514s | 2m19.479s | 2m19.861s |
进程线程数:255 / 打印次数:500
第1次 | 第2次 | |
---|---|---|
多进程 | 9m39s | 9m17s |
多线程 | 9m31s | 9m22s |
进程线程数:255 / 打印次数:2000 (实验太耗时,因此只进行了2轮比对)
【实验结论】
从上面的实验比对结果看,即使Linux2.6使用了新的NPTL线程库(据说比原线程库性能提高了很多,唉,又是据说!),多线程比较多进程在效率上没有任何的优势,在线程数增大时多线程程序还出现了运行错误,实验可以得出下面的结论:
在Linux2.6上,多线程并不比多进程速度快,考虑到线程栈的问题,多进程在并发上有优势。
四、多进程和多线程在创建和销毁上的效率比较
预先创建进程或线程可以节省进程或线程的创建、销毁时间,在实际的应用中很多程序使用了这样的策略,比如Apapche预先创建进程、Tomcat预先创建线程,通常叫做进程池或线程池。在大部分人的概念中,进程或线程的创建、销毁是比较耗时的,在stevesn的著作《Unix网络编程》中有这样 的对比图(第一卷 第三版 30章 客户/服务器程序设计范式):
行号 | 服务器描述 | 进程控制CPU时间(秒,与基准之差) | ||
---|---|---|---|---|
Solaris2.5.1 | Digital Unix4.0b | BSD/OS3.0 | ||
0 | 迭代服务器(基准测试,无进程控制) | 0.0 | 0.0 | 0.0 |
1 | 简单并发服务,为每个客户请求fork一个进程 | 504.2 | 168.9 | 29.6 |
2 | 预先派生子进程,每个子进程调用accept | 6.2 | 1.8 | |
3 | 预先派生子进程,用文件锁保护accept | 25.2 | 10.0 | 2.7 |
4 | 预先派生子进程,用线程互斥锁保护accept | 21.5 | ||
5 | 预先派生子进程,由父进程向子进程传递套接字 | 36.7 | 10.9 | 6.1 |
6 | 并发服务,为每个客户请求创建一个线程 | 18.7 | 4.7 | |
7 | 预先创建线程,用互斥锁保护accept | 8.6 | 3.5 | |
8 | 预先创建线程,由主线程调用accept | 14.5 | 5.0 |
stevens已驾鹤西去多年,但《Unix网络编程》一书仍具有巨大的影响力,上表中stevens比较了三种服务器上多进程和多线程的执行效 率,因为三种服务器所用计算机不同,表中数据只能纵向比较,而横向无可比性,stevens在书中提供了这些测试程序的源码(也可以在网上下载)。书中介 绍了测试环境,两台与服务器处于同一子网的客户机,每个客户并发5个进程(服务器同一时间最多10个连接),每个客户请求从服务器获取4000字节数据, 预先派生子进程或线程的数量是15个。
第0行是迭代模式的基准测试程序,服务器程序只有一个进程在运行(同一时间只能处理一个客户请求),因为没有进程或线程的调度切换,因此它的速度是 最快的,表中其他服务模式的运行数值是比迭代模式多出的差值。迭代模式很少用到,在现有的互联网服务中,DNS、NTP服务有它的影子。第1~5行是多进 程服务模式,期中第1行使用现场fork子进程,2~5行都是预先创建15个子进程模式,在多进程程序中套接字传递不太容易(相对于多线程),stevens在这里提供了4个不同的处理accept的方法。6~8行是多线程服务模式,第6行是现场为客户请求创建子线程,7~8行是预先创建15个 线程。表中有的格子是空白的,是因为这个系统不支持此种模式,比如当年的BSD不支持线程,因此BSD上多线程的数据都是空白的。
从数据的比对看,现场为每客户fork一个进程的方式是最慢的,差不多有20倍的速度差异,Solaris上的现场fork和预先创建子进程的最大差别是504.2 :21.5,但我们不能理解为预先创建模式比现场fork快20倍,原因有两个:
1. stevens的测试已是十几年前的了,现在的OS和CPU已起了翻天覆地的变化,表中的数值需要重新测试。
2. stevens没有提供服务器程序整体的运行计时,我们无法理解504.2 :21.5的实际运行效率,有可能是1504.2 : 1021.5,也可能是100503.2 : 100021.5,20倍的差异可能很大,也可能可以忽略。
因此我写了下面的实验程序,来计算在Linux2.6上创建、销毁10万个进程/线程的绝对用时。
创建10万个进程(forkcreat.c):
创建10万个线程(pthreadcreat.c):
创建10万个线程的Java程序:
在我的赛扬1.5G的CPU上测试结果如下(仍采用测试5次后计算平均值):
创建销毁10万个进程 | 创建销毁10万个线程 | 创建销毁10万个线程(Java) |
---|---|---|
0m18.201s | 0m3.159s | 12286毫秒 |
从数据可以看出,多线程比多进程在效率上有5~6倍的优势,但不能让我们在使用那种并发模式上定性,这让我想起多年前政治课上的一个场景:在讲到优 越性时,面对着几个对此发表质疑评论的调皮男生,我们的政治老师发表了高见,“不能只横向地和当今的发达国家比,你应该纵向地和过去中国几十年的发展历史 比”。政治老师的话套用在当前简直就是真理,我们看看,即使是在赛扬CPU上,创建、销毁进程/线程的速度都是空前的,可以说是有质的飞跃的,平均创建销 毁一个进程的速度是0.18毫秒,对于当前服务器几百、几千的并发量,还有预先派生子进程/线程的必要吗?
预先派生子进程/线程比现场创建子进程/线程要复杂很多,不仅要对池中进程/线程数量进行动态管理,还要解决多进程/多线程对accept的“抢”问题,在stevens的测试程序中,使用了“惊群”和“锁”技术。即使stevens的数据表格中,预先派生线程也不见得比现场创建线程快,在 《Unix网络编程》第三版中,新作者参照stevens的测试也提供了一组数据,在这组数据中,现场创建线程模式比预先派生线程模式已有了效率上的优 势。因此我对这一节实验下的结论是:
预先派生进程/线程的模式(进程池、线程池)技术,不仅复杂,在效率上也无优势,在新的应用中可以放心大胆地为客户连接请求去现场创建进程和线程。
我想,这是fork迷们最愿意看到的结论了。
五、并发服务的不可测性
看到这里,你会感觉到我有挺进程、贬线程的论调,实际上对于现实中的并发服务具有不可测性,前面的实验和结论只可做参考,而不可定性。对于不可测性,我举个生活中的例子。
这几年在大都市生活的朋友都感觉城市交通状况越来越差,到处堵车,从好的方面想这不正反应了我国GDP的高速发展。如果你7、8年前来到西安市,穿 过南二环上的一些十字路口时,会发现一个奇怪的U型弯的交通管制,为了更好的说明,我画了两张图来说明,第一张图是采用U型弯之前的,第二张是采用U型弯 之后的。
为了讲述的方便,我们不考虑十字路口左拐的情况,在图一中东西向和南北向的车辆交汇在十字路口,用红绿灯控制同一时间只能东西向或南北向通行,一般 的十字路口都是这样管控的。随着车辆的增多,十字路口的堵塞越来越严重,尤其是上下班时间经常出现堵死现象。于是交通部门在不动用过多经费的情况下而采用 了图二的交通管制,东西向车辆行进方式不变,而南北向车辆不能直行,需要右拐到下一个路口拐一个超大的U型弯,这样的措施避免了因车辆交错而引发堵死的次 数,从而提高了车辆的通过效率。我曾经问一个每天上下班乘公交经过此路口的同事,他说这样的改动不一定每次上下班时间都能缩短,但上班时间有保障了,从而 迟到次数减少了。如果今天你去西安市的南二环已经见不到U型弯了,东西向建设了高架桥,车辆分流后下层的十字路口已恢复为图一方式。
从效率的角度分析,在图一中等一个红灯45秒,远远小于图二拐那个U型弯用去的时间,但实际情况正好相反。我们可以设想一下,如果路上的所有运行车 辆都是同一型号(比如说全是QQ3微型车),所有的司机都遵守交规,具有同样的心情和性格,那么图一的通行效率肯定比图二高。现实中就不一样了,首先车辆 不统一,有大车、小车、快车、慢车,其次司机的品行不一,有特别遵守交规的,有想耍点小聪明的,有性子慢的,也有的性子急,时不时还有三轮摩托逆行一下, 十字路口的“死锁”也就难免了。
那么在什么情况下图二优于图一,是否能拿出一个科学分析数据来呢?以现在的科学技术水平是拿不出来的,就像长期的天气预报不可预测一样,西安市的交管部门肯定不是分析各种车辆的运行规律、速度,再进行复杂的社会学、心理学分析做出U型弯的决定的,这就是要说的不可测性。
现实中的程序亦然如此,比如WEB服务器,有的客户在快车道(宽带),有的在慢车道(窄带),有的性子慢(等待半分钟也无所谓),有的性子急(拼命 的进行浏览器刷新),时不时还有一两个黑客混入其中,这种情况每个服务器都不一样,既是是同一服务器每时每刻的变化也不一样,因此说不具有可测性。开发者 和维护者能做的,不论是前面的这种实验测试,还是对具体网站进行的压力测试,最多也就能模拟相当于QQ3通过十字路口的场景。
结束语
本篇文章比较了Linux系统上多线程和多进程的运行效率,在实际应用时还有其他因素的影响,比如网络通讯时采用长连接还是短连接,是否采用select、poll,java中称为nio的机制,还有使用的编程语言,例如Java不能使用多进程,PHP不能使用多线程,这些都可能影响到并发模 式的选型。
最后还有两点提醒:
1. 文章中的所有实验数据有环境约束:Linux2.6,单核单U的i386处理器。
2. 由于并行服务的不可测性,文章中的观点应该只做参考,而不要去定性。