多进程与多线程区别
关于在Unix环境下选择使用多线程还是多进程的问题已有较长时间的争议。这一争论最常出现在C/S架构中的服务端并发技术选择阶段。例如,在Web服务器领域中就存在这一问题的不同表现形式:Apache采用了基于perfork模式的设计:每个客户端连接都会分配一个独立的进程,并且每个进程中只有一个执行线程;而像Java这样的语言,则通过其Web容器如Tomcat、Websphere实现了多线程设计:每个客户端连接都会对应一个独立运行的线程,并且所有这些线程共享同一个进程中运行。
考察Unix的发展历程可知,在Unix的诞生过程中伴随而生的就是线程这一概念。然而,在相当长的一段时间内,系统的官方支持还未能普及。举个例子来说,在内核2.6之前的所有版本中,并没有提供符合Posix规范的标准级NPTL(Normal Priority Thread Library)。具体而言,在操作系统层面上来看,进程与线程各自的特点及优缺点如下所示:其中进程的优势主要体现在资源管理上的高效性以及对I/O设备的灵活调度能力;而在线程方面,则强调了单体性以及良好的并发执行能力;由此可见,在选择使用何种机制时应根据具体的系统需求来权衡利弊。
过程优势在于编程与调试操作简便且具有较高的可靠性。
过程在创建、销毁及切换操作上速度较慢且对内存与资源使用量较大。
线程具备快速的创建与切换特性但其对内存与资源消耗相对较高。
线程相较于过程而言其开发与维护难度较大且整体可靠性较弱。
通过对比可以看出一个观点:线程运行速度快且进程具备较高的可靠性。有时会被称作"轻量级进程"的线程,在效率上可能达到数十倍甚至数百倍的优势。然而,在互不共享数据且无需锁机制的情况下,则展现出较高的稳定性与安全性。相比之下,在系统稳定性方面显得较为可靠。这一观点似乎能够被大多数接受因为它与我们已有的知识概念相吻合。
在撰写这篇文章之前,我也同样属于一大部分人,过去两年里编写的一些C语言编写的C/S通讯程序中,因时间紧迫总是采用多进程并发技术,当时一直担忧当并发量增大时系统的负载能否承受得住,因此便打算等到条件成熟后再将其改为多线程架构或者预先创建进程的方式.直到最近通过网络了解到一篇名为《Linux系统下多线程与多进程性能分析》的文章,作者为'周丽 焦程波 兰巨龙',才开始认真思考这一问题.经过自己动手实验,得出的结论与论文作者不谋而合,但这一发现对于大多数人来说无疑具有革命性意义.
为此而设计的实验流程及其结果展示,请问具体形式如何?如有兴趣,则可一同探讨。
基于周丽论文提供的代码框架,在原有基础上我对原有代码进行了小幅改动,并且特别提及时这种区别所在。
论文实验与我的实验在时间维度上存在差异,在时间轴上对应的Linux内核版本分别为不同阶段:其中针对论文研究的系统设计采用的是基于Linux核心框架的开发环境(其内核版本设定于2.4),而我的实验系统则采用了更为现代的Linux核心框架支持(其中线程机制基于NPTL框架)。
在硬件配置方面存在差异:论文实验所处环境的具体配置包括主处理器为Celeron 2.0 GHz、内存容量为256MB、操作系统版本为Linux 9.2、软件内核版本设定在2.4.8;而我的工作本本则采用了不同的硬件架构与操作系统组合:主处理器型号为Celeron(R) M 1.5 GHz、内存容量扩大至1.5GB、操作系统则运行于Ubuntu 10.04桌面环境(其软件内核版本设定在2.6.32)。
进程实验代码(fork.c):
#include <stdlib.h>
#include <stdio.h>
#include <signal.h>
#define P_NUMBER 255 /* 并发进程数量 */
#define COUNT 100 /* 每进程打印字符串次数 */
#define TEST_LOGFILE "logFile.log"
FILE *logFile = NULL;
char *s = "hello linux\0";
int main ( )
{
int i = 0,j = 0;
logFile = fopen (TEST_LOGFILE, "a+" ); /* 打开日志文件 */
for (i = 0; i < P_NUMBER; i++ )
{
if (fork ( ) == 0 ) /* 创建子进程,if(fork() == 0){}这段代码是子进程运行区间 */
{
for (j = 0;j < COUNT; j++ )
{
printf ( "[%d]%s\n", j, s ); /* 打印到标准输出流 */
fprintf (logFile, "[%d]%s\n", j, s ); /* 向日志文件输出 */
}
exit ( 0 ); /* 子进程结束 */
}
}
for (i = 0; i < P_NUMBER; i++ ) /* 回收子进程 */
{
wait ( 0 );
}
printf ( "OK\n" );
return 0;
}
进程实验代码(thread.c):
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#define P_NUMBER 255 /* 并发线程数量 */
#define COUNT 100 /* 每线程打印字符串次数 */
#define Test_Log "logFIle.log"
FILE *logFile = NULL;
char *s = "hello linux\0";
print_hello_linux ( ) /* 线程执行的函数 */
{
int i = 0;
for (i = 0; i < COUNT; i++ )
{
printf ( "[%d]%s\n", i, s ); /* 向控制台输出 */
fprintf (logFile, "[%d]%s\n", i, s ); /* 向日志文件输出 */
}
pthread_exit ( 0 ); /* 线程结束 */
}
int main ( )
{
int i = 0;
pthread_t pid [P_NUMBER ]; /* 线程数组 */
logFile = fopen (Test_Log, "a+" ); /* 打开日志文件 */
for (i = 0; i < P_NUMBER; i++ )
pthread_create (&pid [i ], NULL, ( void * )print_hello_linux, NULL ); /* 创建线程 */
for (i = 0; i < P_NUMBER; i++ )
pthread_join (pid [i ], NULL ); /* 回收线程 */
printf ( "OK\n" );
return 0;
}
两段程序具有相似功能,在行为上存在一致性:它们均会生成预定数量的进程或线程;每个启动的进程或线程都会将固定数量的信息输出到控制台窗口及日志记录中;这些信息量由预定义的变量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次、1 万次、2 万次直至9 万次打印操作,并记录相应的耗时数据。研究发现,在处理大量任务时采用多进程策略更为高效;而当任务规模较小时,则更适合使用多线程来进行处理;有趣的是,在重复打印6 万次的任务中,两种策略所需的时间几乎相同。
虽然我的实验在经过5000次打印次数后才最终实现对单核态的超越, 但这并非偶然, 因为所采用的NPTL线程库特性为其提供了理论基础. 经过大量测试, 多核模式与非阻塞模式所得性能指标差异极小, 这一现象的发生源于数据采集过程具有瞬时性特征, 因此可推断两者运行速度基本一致.
在当今网络环境下,我们特别关注系统在高强度并发处理能力与负载情况下的表现。回顾先前的实验流程,在整个实验的最大运行时长控制在1分钟左右的情况下完成了各项指标测试。因此接下来将从两个维度展开分析:一是增加系统的并发处理数量;二是提升单进程/单线程的任务处理强度。
二、增加并发数量的实验
在实验中打印次数保持不变的情况下(记作ℕ),随着进程/线程数量逐步增加(ℙ),多线程程序在后三组测试中(包括5个、8个和1个进程/并行单元)均出现了"段错误"现象(图中的Δ值)。这种异常情况的发生原因与系统资源分配策略中的线程栈大小设置相关联。
实验所使用的计算机CPU型号为32位赛扬处理器,在最大寻址范围内能够覆盖4GB(即2^32字节)的空间。在Linux操作系统下,默认内存分配比例设定为3GB/1GB(其中1GB属于所有进程共享的内核空间区域),而剩余的3GB则被划分为独立于内核空间之外的用户空间(即进程虚拟内存空间)。对于单个进程而言,在其运行过程中仅需维护一个虚拟地址栈即可完成所有操作;然而该栈的最大容量通常能满足大部分需求。但对于单个线程而言,则必须独立维护一个线程栈,在这种情况下,默认情况下每个线程将占用约3GB的空间资源(计算时需扣除程序文本、数据区域以及公共库占用的空间)。因此当运行大量线程时会导致各线程栈占用总量显著增加而超出单个进程虚拟内存容量限制的情形就成为了本实验中出现的问题根源
Linux 2.6内核默认设置了8MB的线程栈大小,在运行ulimit -s命令能够查看当前设置并允许对其进行修改操作。通过计算公式(1024³ × 3)/(1024² × 8),我们得出最大可能的线程数为:384个;但实际上由于程序代码、数据区域以及共享库占用内存空间的原因,实际值会略低于这个理论值。在当今较为繁忙的Web服务器环境中,在超过384个并发连接时并不罕见;因此为了后续实验需求需要减小默认线程栈大小以降低系统负载;但这种做法存在潜在风险:当单个线程函数分配了大量自动生成变量或者涉及较深层次栈帧(如递归调用)时;可能导致系统出现内存不足的问题;这时可以通过遵循POSIX.1标准中定义的两个关键属性——guardsize和stackaddr来应对这一问题;通过设置guardsize为一定值并调节stackaddr参数;能够在发生栈溢出时捕获系统内核警告信号并动态扩展可用内存空间
有两种主要的方法可用以调整线程栈容量。其中一种是通过执行ulimit -s命令来修改系统的默认值;另一种则是在编写代码时利用pthread_attr_setstacksize函数来指定单个线程的堆栈大小。在本实验中采用的是第一种方法;具体操作是在启动程序之前先通过ulimit命令将系统的默认值设定为1兆字节(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程序。然而, 发现将虚拟机初始化的过程相对耗时, 因此没有采用time命令来测量运行时间, 而是将测时结果直接记录在代码中。对于那些不熟悉C编程但希望学习Linux系统内核原理的 Java 程序员来说, 这个工具依然非常有用
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
public class MyThread extends Thread
{
static int P_NUMBER = 1000; /* 并发线程数量 */
static int COUNT = 1000; /* 每线程打印字符串次数 */
final String s = "hello linux\n";
static [FileOutputStream](http://www.google.com/search?hl=en&q=allinurl%3AFileOutputStream+java.sun.com&btnI=I'm Feeling Lucky) outputStream = null; /* 用于文件操作的对象 */
@Override
public void run ( )
{
for ( int i = 0; i < COUNT; i++ )
{
The System class method for retrieving http://www.google.com/search?hl=en&q=allinurl%3ASystem+java.sun.com&btnI=I'm Feeling Lucky outputs the result to the console window using a formatted identifier and a string variable in a print statement or print operation.
StringBuilder sb = new StringBuilder ( 16 );
sb. append ( "[" ). append (i ). append ( "]" ). append (s );
try
{
out. write (sb. toString ( ). getBytes ( ) ); /* 向日志文件输出 */
}
catch ( [IOException](http://www.google.com/search?hl=en&q=allinurl%3AIOException+java.sun.com&btnI=I'm Feeling Lucky) throws [IOException](http://www.google.com/search?hl=en&q=allinurl%3AIOException+java.sun.com&btnI=I'm Feeling Lucky) 异常实例 e )
{
e. printStackTrace ( );
}
}
}
public static void main (Argument[] args) throws [FileNotFoundException](http://www.google.com/search?hl=en&q=allinurl%3AFileNotFoundException+java.sun.com&btnI=I'm Feeling Lucky), [InterruptedException](http://www.google.com/search?hl=en&q=allinurl%3AInterruptedException+java.sun.com&btnI=I'm Feeling Lucky)
{
MyThread [ ] threads = new MyThread [P_NUMBER ]; /* 线程数组 */
该变量file被赋值为新File对象的构造函数结果。
out = new FileOutputStream(allinurl(FileOutputStream))(file, true); /* 日志文件输出流(用于记录操作)*/
System.out.printLine(): 启动操作: 打印'开始运行'
long start = new System().currentTimeMillis();
for ( int i = 0; i < P_NUMBER; i++ ) //创建线程
{
threads [i ] = new MyThread ( );
threads [i ]. start ( );
}
for ( int i = 0; i < P_NUMBER; i++ ) //回收线程
{
threads [i ]. join ( );
}
打印到标准输出流耗时:" + (计算时间) + " 毫秒;
return;
}
}
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 | |
|---|---|---|---|---|---|---|
| Java | 65664 毫秒 | 66269 毫秒 | 65546 毫秒 | 65931 毫秒 | 66540 毫秒 | 65990 毫秒 |
线程数:1000 / 打印次数:1000
从情理上讲,并非C程序快得多的Java程序依然稍微慢一点。然而,在Java程序运行过程中并未发生线程栈溢出问题。所有5次测试均顺利完成且运行稳定。可以使用以下ps命令获取java进程中的线段数量:
diaoyf@ali:~$ ps -eLf | grep MyThread | wc -l
1010
通过ps工具观察到,在虚拟机环境中运行于堆栈地址1010处的进程长时间保持活跃状态。随后分析发现这些额外增加的这十一个进程很可能是虚拟机自身的管理进程。我不清楚Java在创建新进程时所使用的默认栈大小是多少,在此过程中由于部分资料难以获取所需信息而遗憾的是,在此过程中并未发现所需的相关文档或资料。针对虚拟机环境的各项参数设置我发现Java提供了控制参数选项因此为了进一步验证此假设决定采用以下命令设置虚拟机环境
diaoyf@ali:~/tmp1$ java -Xss8192k MyThread
并非如我们所料,并未出现预期中的异常情况。然而,在某个时刻通过使用ps工具检测,在某个时刻线程数量达到了最高值337。经过分析发现,在可用内存达到上限之前,并未继续增加进程数量。运行时间明显低于预期水平的结果与之前的结论一致。尽管程序并未触发任何异常事件,并且在性能指标方面仍存在不足之处——此外,在最终结果中也未输出‘耗时:xxx毫秒’这一关键数据项。
通过本次测试我对这一长期存在的假设得到了进一步的支持:我发现Java Web容器确实存在稳定性问题。由于我拥有多年的Java B/S开发经验,并观察到Web服务偶尔崩溃的现象并非偶然。除了个人技术水平和代码质量之外,请问您是否也认为可能存在更根本的原因?如果是线程相关的问题的话,请问这可能比本文讨论的多进程性能问题更为严重吗?想想全球有多少服务器还在运行Tomcat、Jboss、Websphere或weblogic等应用服务器呢?嘿嘿
此次测试推翻了此前的一个观点:在单个CPU核心上实现成百上千个并发请求时,在进程或线程切换过程中将会导致 server CPU资源被大量耗尽而导致 server运行效率急剧下降。然而通过实验发现,在进程/线程数量达到1,000级(相当于非常繁忙的Web服务器配置)时,则仍能维持良好的性能水平
三、增加每进程/线程的工作强度的实验
这次将程序打印数据增大,原来打印字符串为:
char *s = "hello linux\0";
现在修改为每次打印256个字节数据:
char *s = "1234567890abcdef\
1234567890abcdef\
1234567890abcdef\
1234567890abcdef\
1234567890abcdef\
1234567890abcdef\
1234567890abcdef\
1234567890abcdef\
1234567890abcdef\
1234567890abcdef\
1234567890abcdef\
1234567890abcdef\
1234567890abcdef\
1234567890abcdef\
1234567890abcdef\
1234567890abcdef\0";
| 第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线程库(其中关于其性能相较于旧库的提升存在较大争议),在多进程处理效率方面,并没有显示出明显的优势。当线程数量增加时,并发程序还出现了运行错误。基于上述对比分析的结果可以看出:
基于Linux内核2.6内态的环境中,在处理单一同一个目标时运行效率较单进程中程组略逊一筹。然而,在处理高并发任务时展现出更强的优势。
四、多进程和多线程在创建和销毁上的效率比较
提前建立进程或线程能够节省建立和销毁这些资源所需的时间。在实际应用中许多程序采用了这一策略,在某些情况下甚至会事先建立一个进程池或者一个线程池。例如Apache事先建立了这样一个进程池而Tomcat则采用了类似的策略以实现对一个线程池的管理。普遍认为这种方法往往被认为是一个耗时的操作参考书籍中的描述(第一卷 第三版 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进行了详细对比分析三种不同服务器上的多进程与多线程执行效率。由于三种服务器所使用的计算平台不同,在表中仅能进行纵向数据比较而无法横向对比参考价值。本书不仅提供了这些测试程序的源码(可以在网上获取),还详细介绍了测试环境:两台运行于同一子网的客户机设备,在每台客户机上同时并行运行5个进程(在同一时间最多支持10个网络连接),每个客户端请求从服务器下载4000字节的数据,在此前提生子进程或线程数量为15个。
基准测试程序位于第0行位置上,在服务器端只运行单个进程以处理客户的请求(在同一时间段内仅可处理一个客户请求),因此其运行速度处于最高水平状态;对比迭代模式的数据,在表中其他服务模式的表现均有所提升。迭代模式由于其特性较少应用于实际场景,在现有的互联网服务系统中仍可见到其间接应用痕迹:例如在DNS、NTP等服务系统中都有其影子存在。而从第1至5行的内容展示的是多进程服务模式的不同实现方式:其中第1行采用现场fork子进程的方式进行操作(相对于多线程而言),而其余几行则采用了预先创建固定数量子进程中接字传递的方式;随后从第6至8行依次展开讨论的是多线程服务模式:其中第6行为每个客户服务请求单独创建子线程以完成任务(相对而言这种方式更加灵活高效),而7至8行为预先定义好固定数量子线程并等待请求处理的情况;在表格数据呈现方面需要注意以下几点:首先系统不支持相应功能时,在表格中对应单元将留空未填;例如当年的BSD系统由于缺乏相应的操作系统内核支持而无法实现线程级别的功能开发
基于数据对比分析可知,在逐个启动子进程中发现对于每个客户而言,在现场环境下启动子进程的方式效率较低。其速度差异达到约20倍。在Solaris系统中进行比较时发现,在预创建子进程中最大速度差异达到了504.2:21.5的比例。需要注意的是,并不能简单认为预创建模式比逐个启动子进程快了约两倍。
stevens的测试已经是多年以前的事了, 现在运行着的os系统与cpu芯片相比已经发生了翻天覆地的变化, 表中列出的数据则需要重新测定.
第二. stevens并未对整个服务器程序的运行时间进行详细记录,在这种情况下我们也就无法准确理解诸如"5O4\.2:21.5"之类的实际运行效率值究竟是什么含义。\ 因此对于实际运行效率如"504.2:21.5"这样的数据而言,在其数值范围上可能存在一定的偏差或变异性。\ 其具体数值可能落在"1504.2:1021.5"这一区间内或者也有可能出现在"100503.2:100021.5"这个更大的范围内。\ 这两者之间的具体差异程度可能因具体情况而异。
为此我编写了该实验程序用于计算基于Linux 2.6平台生成并销毁总共100,000个进程和线程所需的耗时数据
创建10万个进程(forkcreat.c):
#include <stdlib.h>
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/wait.h>
int count; /* 子进程创建成功数量 */
int fcount; /* 子进程创建失败数量 */
int scount; /* 子进程回收数量 */
/* 信号处理函数–子进程关闭收集 */
void sig_chld ( int signo )
{
pid_t chldpid; /* 子进程id */
int stat; /* 子进程的终止状态 */
/* 子进程回收,避免出现僵尸进程 */
while ( (chldpid = wait (&stat ) ) > 0 )
{
scount++;
}
}
int main ( )
{
/* 注册子进程回收信号处理函数 */
signal (SIGCHLD, sig_chld );
int i;
for (i = 0; i < 100000; i++ ) //fork()10万个子进程
{
pid_t pid = fork ( );
if (pid == -1 ) //子进程创建失败
{
fcount++;
}
else if (pid > 0 ) //子进程创建成功
{
count++;
}
else if (pid == 0 ) //子进程执行过程
{
exit ( 0 );
}
}
该C语言中的printf函数用于将整数值依次输出为count、fcount和scount变量的值
}
创建10万个线程(pthreadcreat.c):
#include <stdio.h>
#include <pthread.h>
int count = 0; /* 成功创建线程数量 */
void thread ( void )
{
/* 线程啥也不做 */
}
int main ( void )
{
pthread_t id; /* 线程id */
int i,ret;
for (i = 0; i < 100000; i++ ) /* 创建10万个线程 */
{
ret = pthread_create (&id, NULL, ( void * )thread, NULL );
if (ret != 0 )
{
调用该函数并传递错误信息字符串 'Create pthread error!'
return ( 1 );
}
count++;
pthread_join (id, NULL );
}
printf ("计数器值: %d 换行符", count);
}
创建10万个线程的Java程序:
public class ThreadTest
{
public static Main.main(String[] args) throws InterruptedException {
// ... code ...
}
{
[System](http://www.google.com/search?hl=en&q=allinurl%3ASystem+java.sun.com&btnI=I'm Feeling Lucky). out. System.out.println( "开始运行" );
长整型字段start被初始化为System类的实例,并调用currentTimeMillis()方法。
for ( int i = 0; i < 100000; i++ ) //创建10万个线程
{
Thread athread = new Thread ( ); //创建线程对象
athread. start ( ); //启动线程
athread. join ( ); //等待该线程停止
}
System.out.println("耗时:" + (System.currentTimeMillis() - start) + " 毫秒");
}
}
在我的赛扬1.5G的CPU上测试结果如下(仍采用测试5次后计算平均值):
| 创建销毁10万个进程 | 创建销毁10万个线程 | 创建销毁10万个线程(Java) |
|---|---|---|
| 0m18.201s | 0m3.159s | 12286毫秒 |
分析数据发现,在提升效率方面多线程表现出了显著的优势,在某些场景下甚至达到了5到6倍的增长幅度。这种现象促使我想起了大学政治课上老师的一番话:当我们讨论优越性时,单纯的横向比较已经无法满足需求,必须采取纵向对比过去几十年的发展历程更为合理。我们的政治老师提出了自己的见解,"不能只横向地和当今的发达国家比,单纯的横向比较已经无法满足需求"这个观点,即使是在现代服务器平台中,平均创建销毁一个进程的速度也达到了惊人的0.18毫秒,这样的速度是否有必要预先生成子进程或线程呢?
相比直接创建子进程与线程而言,在实现上具有更高的复杂度。在动态管理池中进程中程与线程数量方面,并非没有挑战性。同时需要解决多进程之间争抢资源的问题,在stevens的测试程序中使用了"quies"(可能指"quies"为错误猜测)技术和"synchronized"(同步)机制。即使在stevens的数据表格中也未见预先派生出的线程比现场创建的线 thread更快,在《Unix网络编程》第三版一书中新作者参考stevens的数据集也提供了一组新的测试结果:在这些数据中基于实时创建线 thread模式比预先生成子进程模式已经获得了显著的效率优势。因此我对这一节实验得出如下结论:
预先生成进程/线程的方式(进程池、线程池)属于一种较为复杂的计算模式,在其运行效率方面并无明显优势。
对于新的应用场景而言,在处理客户连接请求时可直接生成所需进程与线程。
我想,这是fork迷们最愿意看到的结论了。
五、并发服务的不可测性
在这里阅读时会有一种我对作业与不稳定线程组论调感到不安的感觉;实际上,在现实环境中处理并发服务时存在明显的不确定性问题;前面提到的实验结果只能作为参考依据;而对于这一现象我将举一个生活中的实例来说明
这些年在大城市生活的朋友普遍感受到城市交通状况日益恶化。这也直接反映了我国GDP增长速度的大幅提升。几年前访问西安时,穿过南二环上的某些十字路口时会发现原本设计的一个U型转弯被改造成了一个异常复杂的立体交叉结构。为了更好地解释这一变化过程,在此特别绘制了两张对比图表。第一幅展示了安装U型弯之前的交通状况,并标注为"安装前状态";第二幅则显示安装后的效果,并标注为"改进后效果"。
南二环交通图一
南二环交通图二
为了叙述方便起见
从效率的角度来看,在图一中等待一个红灯大约45秒,在图二中则需要拐一个U型弯所花费的时间要少得多;然而实际情况与之恰恰相反。如果我们设想一下:如果道路运行车辆均为同一型号(例如全部是QQ3微型车),并且所有驾驶员都严格遵守交通法规、性格相似且心态一致,则图一的通行效率自然会高于图二;但现实情况并非如此:首先车辆类型不一(有大车、小车、快车与慢车等),其次驾驶员素质参差不齐(有严格遵守交通规则者、喜欢耍小聪明者、性子急躁者以及偶尔逆行的三轮摩托车手等),这使得十字路口出现“死锁”现象不可避免地发生
那么在何种条件下图二会超越图一?是否能够提供一个科学的数据来进行对比?当前技术水平难以实现这一目标。如同长期气象预报无法准确预测一般。西安交通管理部门显然不会通过分析车辆运行规律和速度来进行这样的决策,并结合社会学与心理学因素综合考虑。这即被视为一种不可预知性。
同样地,在现实中的程序中也是如此。例如,在Web服务器领域中就存在这样的情况:有些客户处于快速通道(即宽带连接)的状态;而另一些则缓慢移动(窄带网络)。那些比较温顺的用户可以耐心等待半分钟而不着急;而对于那些过于急躁的用户,则可能频繁地刷新网页页面——偶尔会有黑客渗透进来。每个服务器的具体情况各不相同;而且即使是同一台服务器,在不同时刻也会有不同的行为模式;因此无法简单地对其进行测量或评估其稳定性。开发人员和运维团队能够实施的一些措施——最多也只能达到类似QQ3在十字路口的程度
结束语
本文深入分析了Linux系统环境下多线程与多进程执行效率的区别,并指出实际应用中还存在多种额外因素的影响。例如,在网络安全配置中选择长连接还是短连接,在操作系统调优设置中选择是否启用select或poll机制(因为直接引用Java中的NIO机制),以及所使用的编程语言限制(如Java禁止使用标准库中的multi-threading类库)等参数设置都会对最终系统的性能产生显著影响。
