一.为何需要多进程(或者多线程),为何需要并发?
这个问题或许本身都不是个问题。但是对于没有接触过多进程编程的朋友来说,他们确实无法感受到并发的魅力以及必要性。
我想,只要你不是整天都写那种int main()到底的代码的人,那么或多或少你会遇到代码响应不够用的情况,也应该有尝过并发编程的甜头。就像一个快餐点的服务员,既要在前台接待客户点餐,又要接电话送外卖,没有分身术肯定会忙得你焦头烂额的。幸运的是确实有这么一种技术,让你可以像孙悟空一样分身,灵魂出窍,乐哉乐哉地轻松应付一切状况,这就是多进程/线程技术。
并发技术,就是可以让你在同一时间同时执行多条任务的技术。你的代码将不仅仅是从上到下,从左到右这样规规矩矩的一条线执行。你可以一条线在main函数里跟你的客户交流,另一条线,你早就把你外卖送到了其他客户的手里。
所以,为何需要并发?因为我们需要更强大的功能,提供更多的服务,所以并发,必不可少。
二.多进程
什么是进程。最直观的就是一个个pid,官方的说法就:进程是程序在计算机上的一次执行活动。
说得简单点,下面这段代码执行的时候
- int main()
- {
- printf(”pid is %d/n”,getpid() );
- return 0;
- }
- int main()
- {
- printf(”pid is %d/n”,getpid() );
- return 0;
- }
进入main函数,这就是一个进程,进程pid会打印出来,然后运行到return,该函数就退出,然后由于该函数是该进程的唯一的一次执行,所以return后,该进程也会退出。
看看多进程。linux下创建子进程的调用是fork();
- #include <unistd.h>
- #include <sys/types.h>
- #include <stdio.h>
- void print_exit()
- {
- printf("the exit pid:%d/n",getpid() );
- }
- main ()
- {
- pid_t pid;
- atexit( print_exit ); //注册该进程退出时的回调函数
- pid=fork();
- if (pid < 0)
- printf("error in fork!");
- else if (pid == 0)
- printf("i am the child process, my process id is %d/n",getpid());
- else
- {
- printf("i am the parent process, my process id is %d/n",getpid());
- sleep(2);
- wait();
- }
- }
- #include <unistd.h>
- #include <sys/types.h>
- #include <stdio.h>
- void print_exit()
- {
- printf("the exit pid:%d/n",getpid() );
- }
- main ()
- {
- pid_t pid;
- atexit( print_exit ); //注册该进程退出时的回调函数
- pid=fork();
- if (pid < 0)
- printf("error in fork!");
- else if (pid == 0)
- printf("i am the child process, my process id is %d/n",getpid());
- else
- {
- printf("i am the parent process, my process id is %d/n",getpid());
- sleep(2);
- wait();
- }
- }
i am the child process, my process id is 15806
the exit pid:15806
i am the parent process, my process id is 15805
the exit pid:15805
这是gcc测试下的运行结果。
关于fork函数,功能就是产生子进程,由于前面说过,进程就是执行的流程活动。
那么fork产生子进程的表现就是它会返回2次,一次返回0,顺序执行下面的代码。这是子进程。
一次返回子进程的pid,也顺序执行下面的代码,这是父进程。
(为何父进程需要获取子进程的pid呢?这个有很多原因,其中一个原因:看最后的wait,就知道父进程等待子进程的终结后,处理其task_struct结构,否则会产生僵尸进程,扯远了,有兴趣可以自己google)。
如果fork失败,会返回-1.
额外说下atexit( print_exit ); 需要的参数肯定是函数的调用地址。
这里的print_exit 是函数名还是函数指针呢?答案是函数指针,函数名永远都只是一串无用的字符串。
某本书上的规则:函数名在用于非函数调用的时候,都等效于函数指针。
说到子进程只是一个额外的流程,那他跟父进程的联系和区别是什么呢?
我很想建议你看看linux内核的注解(有兴趣可以看看,那里才有本质上的了解),总之,fork后,子进程会复制父进程的task_struct结构,并为子进程的堆栈分配物理页。理论上来说,子进程应该完整地复制父进程的堆,栈以及数据空间,但是2者共享正文段。
关于写时复制:由于一般 fork后面都接着exec,所以,现在的 fork都在用写时复制的技术,顾名思意,就是,数据段,堆,栈,一开始并不复制,由父,子进程共享,并将这些内存设置为只读。直到父,子进程一方尝试写这些区域,则内核才为需要修改的那片内存拷贝副本。这样做可以提高 fork的效率。
三.多线程
线程是可执行代码的可分派单元。这个名称来源于“执行的线索”的概念。在基于线程的多任务的环境中,所有进程有至少一个线程,但是它们可以具有多个任务。这意味着单个程序可以并发执行两个或者多个任务。
简而言之,线程就是把一个进程分为很多片,每一片都可以是一个独立的流程。这已经明显不同于多进程了,进程是一个拷贝的流程,而线程只是把一条河流截成很多条小溪。它没有拷贝这些额外的开销,但是仅仅是现存的一条河流,就被多线程技术几乎无开销地转成很多条小流程,它的伟大就在于它少之又少的系统开销。(当然伟大的后面又引发了重入性等种种问题,这个后面慢慢比较)。
还是先看linux提供的多线程的系统调用:
int pthread_create(pthread_t *restrict tidp, |
Returns: 0 if OK, error number on failure |
第一个参数为指向线程标识符的指针。
第二个参数用来设置线程属性。
第三个参数是线程运行函数的起始地址。
最后一个参数是运行函数的参数。
- #include<stdio.h>
- #include<string.h>
- #include<stdlib.h>
- #include<unistd.h>
- #include<pthread.h>
- void* task1(void*);
- void* task2(void*);
- void usr();
- int p1,p2;
- int main()
- {
- usr();
- getchar();
- return 1;
- }
- void usr()
- {
- pthread_t pid1, pid2;
- pthread_attr_t attr;
- void *p;
- int ret=0;
- pthread_attr_init(&attr); //初始化线程属性结构
- pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); //设置attr结构为分离
- pthread_create(&pid1, &attr, task1, NULL); //创建线程,返回线程号给pid1,线程属性设置为attr的属性,线程函数入口为task1,参数为NULL
- pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);
- pthread_create(&pid2, &attr, task2, NULL);
- //前台工作
- ret=pthread_join(pid2, &p); //等待pid2返回,返回值赋给p
- printf("after pthread2:ret=%d,p=%d/n", ret,(int)p);
- }
- void* task1(void *arg1)
- {
- printf("task1/n");
- //艰苦而无法预料的工作,设置为分离线程,任其自生自灭
- pthread_exit( (void *)1);
- }
- void* task2(void *arg2)
- {
- int i=0;
- printf("thread2 begin./n");
- //继续送外卖的工作
- pthread_exit((void *)2);
- }
- #include<stdio.h>
- #include<string.h>
- #include<stdlib.h>
- #include<unistd.h>
- #include<pthread.h>
- void* task1(void*);
- void* task2(void*);
- void usr();
- int p1,p2;
- int main()
- {
- usr();
- getchar();
- return 1;
- }
- void usr()
- {
- pthread_t pid1, pid2;
- pthread_attr_t attr;
- void *p;
- int ret=0;
- pthread_attr_init(&attr); //初始化线程属性结构
- pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); //设置attr结构为分离
- pthread_create(&pid1, &attr, task1, NULL); //创建线程,返回线程号给pid1,线程属性设置为attr的属性,线程函数入口为task1,参数为NULL
- pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);
- pthread_create(&pid2, &attr, task2, NULL);
- //前台工作
- ret=pthread_join(pid2, &p); //等待pid2返回,返回值赋给p
- printf("after pthread2:ret=%d,p=%d/n", ret,(int)p);
- }
- void* task1(void *arg1)
- {
- printf("task1/n");
- //艰苦而无法预料的工作,设置为分离线程,任其自生自灭
- pthread_exit( (void *)1);
- }
- void* task2(void *arg2)
- {
- int i=0;
- printf("thread2 begin./n");
- //继续送外卖的工作
- pthread_exit((void *)2);
- }
这个多线程的例子应该很明了了,主线程做自己的事情,生成2个子线程,task1为分离,任其自生自灭,而task2还是继续送外卖,需要等待返回。(因该还记得前面说过僵尸进程吧,线程也是需要等待的。如果不想等待,就设置线程为分离线程)
额外的说下,linux下要编译使用线程的代码,一定要记得调用pthread库。如下编译:
gcc -o pthrea -pthread pthrea.c
四.比较以及注意事项
1.看完前面,应该对多进程和多线程有个直观的认识。如果总结多进程和多线程的区别,你肯定能说,前者开销大,后者开销较小。确实,这就是最基本的区别。
2.线程函数的可重入性:
说到函数的可重入,和线程安全,我偷懒了,引用网上的一些总结。
线程安全:概念比较直观。一般说来,一个函数被称为线程安全的,当且仅当被多个并发线程反复调用时,它会一直产生正确的结果。
可重入:概念基本没有比较正式的完整解释,但是它比线程安全要求更严格。根据经验,所谓“重入”,常见的情况是,程序执行到某个函数foo()时,收到信号,于是暂停目前正在执行的函数,转到信号处理函数,而这个信号处理函数的执行过程中,又恰恰也会进入到刚刚执行的函数foo(),这样便发生了所谓的重入。此时如果foo()能够正确的运行,而且处理完成后,之前暂停的foo()也能够正确运行,则说明它是可重入的。
线程安全的条件:
要确保函数线程安全,主要需要考虑的是线程之间的共享变量。属于同一进程的不同线程会共享进程内存空间中的全局区和堆,而私有的线程空间则主要包括栈和寄存器。因此,对于同一进程的不同线程来说,每个线程的局部变量都是私有的,而全局变量、局部静态变量、分配于堆的变量都是共享的。在对这些共享变量进行访问时,如果要保证线程安全,则必须通过加锁的方式。
可重入的判断条件:
要确保函数可重入,需满足一下几个条件:
1、不在函数内部使用静态或全局数据
2、不返回静态或全局数据,所有数据都由函数的调用者提供。
3、使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
4、不调用不可重入函数。
可重入与线程安全并不等同,一般说来,可重入的函数一定是线程安全的,但反过来不一定成立。它们的关系可用下图来表示:
比如:strtok函数是既不可重入的,也不是线程安全的;加锁的strtok不是可重入的,但线程安全;而strtok_r既是可重入的,也是线程安全的。
如果我们的线程函数不是线程安全的,那在多线程调用的情况下,可能导致的后果是显而易见的——共享变量的值由于不同线程的访问,可能发生不可预料的变化,进而导致程序的错误,甚至崩溃。
3.关于IPC(进程间通信)
由于多进程要并发协调工作,进程间的同步,通信是在所难免的。
稍微列举一下linux常见的IPC.
linux下进程间通信的几种主要手段简介:
- 管道(Pipe)及有名管道(named pipe):管道可用于具有亲缘关系进程间的通信,有名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信;
- 信号(Signal):信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程本身;linux除了支持Unix早期信号语义函数sigal外,还支持语义符合Posix.1标准的信号函数sigaction(实际上,该函数是基于BSD的,BSD为了实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数);
- 报文(Message)队列(消息队列):消息队列是消息的链接表,包括Posix消息队列system V消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。
- 共享内存:使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。
- 信号量(semaphore):主要作为进程间以及同一进程不同线程之间的同步手段。
- 套接口(Socket):更为一般的进程间通信机制,可用于不同机器之间的进程间通信。起初是由Unix系统的BSD分支开发出来的,但现在一般可以移植到其它类Unix系统上:Linux和System V的变种都支持套接字。
或许你会有疑问,那多线程间要通信,应该怎么做?前面已经说了,多数的多线程都是在同一个进程下的,它们共享该进程的全局变量,我们可以通过全局变量来实现线程间通信。如果是不同的进程下的2个线程间通信,直接参考进程间通信。
4.关于线程的堆栈
说一下线程自己的堆栈问题。
是的,生成子线程后,它会获取一部分该进程的堆栈空间,作为其名义上的独立的私有空间。(为何是名义上的呢?)由于,这些线程属于同一个进程,其他线程只要获取了你私有堆栈上某些数据的指针,其他线程便可以自由访问你的名义上的私有空间上的数据变量。(注:而多进程是不可以的,因为不同的进程,相同的虚拟地址,基本不可能映射到相同的物理地址)
5.在子线程里fork
看过好几次有人问,在子线程函数里调用system或者 fork为何出错,或者fork产生的子进程是完全复制父进程的吗?
我测试过,只要你的线程函数满足前面的要求,都是正常的。
- #include<stdio.h>
- #include<string.h>
- #include<stdlib.h>
- #include<unistd.h>
- #include<pthread.h>
- void* task1(void *arg1)
- {
- printf("task1/n");
- system("ls");
- pthread_exit( (void *)1);
- }
- int main()
- {
- int ret=0;
- void *p;
- int p1=0;
- pthread_t pid1;
- pthread_create(&pid1, NULL, task1, NULL);
- ret=pthread_join(pid1, &p);
- printf("end main/n");
- return 1;
- }
- #include<stdio.h>
- #include<string.h>
- #include<stdlib.h>
- #include<unistd.h>
- #include<pthread.h>
- void* task1(void *arg1)
- {
- printf("task1/n");
- system("ls");
- pthread_exit( (void *)1);
- }
- int main()
- {
- int ret=0;
- void *p;
- int p1=0;
- pthread_t pid1;
- pthread_create(&pid1, NULL, task1, NULL);
- ret=pthread_join(pid1, &p);
- printf("end main/n");
- return 1;
- }
上面这段代码就可以正常得调用ls指令。
不过,在同时调用多进程(子进程里也调用线程函数)和多线程的情况下,函数体内很有可能死锁。
关于多进程和多线程,教科书上最经典的一句话是“进程是资源分配的最小单位,线程是CPU调度的最小单位”,这句话应付考试基本上够了,但如果在工作中遇到类似的选择问题,那就没有这么简单了,选的不好,会让你深受其害。
经常在网络上看到有的XDJM问“多进程好还是多线程好?”、“Linux下用多进程还是多线程?”等等期望一劳永逸的问题,我只能说:没有最好,只有更好。根据实际情况来判断,哪个更加合适就是哪个好。
我们按照多个不同的维度,来看看多线程和多进程的对比(注:因为是感性的比较,因此都是相对的,不是说一个好得不得了,另外一个差的无法忍受)。
对比维度 | 多进程 | 多线程 | 总结 |
数据共享、同步 | 数据共享复杂,需要用IPC;数据是分开的,同步简单 | 因为共享进程数据,数据共享简单,但也是因为这个原因导致同步复杂 | 各有优势 |
内存、CPU | 占用内存多,切换复杂,CPU利用率低 | 占用内存少,切换简单,CPU利用率高 | 线程占优 |
创建销毁、切换 | 创建销毁、切换复杂,速度慢 | 创建销毁、切换简单,速度很快 | 线程占优 |
编程、调试 | 编程简单,调试简单 | 编程复杂,调试复杂 | 进程占优 |
可靠性 | 进程间不会互相影响 | 一个线程挂掉将导致整个进程挂掉 | 进程占优 |
分布式 | 适应于多核、多机分布式;如果一台机器不够,扩展到多台机器比较简单 | 适应于多核分布式 | 进程占优 |
1)需要频繁创建销毁的优先用线程
原因请看上面的对比。
这种原则最常见的应用就是Web服务器了,来一个连接建立一个线程,断了就销毁线程,要是用进程,创建和销毁的代价是很难承受的
2)需要进行大量计算的优先使用线程
所谓大量计算,当然就是要耗费很多CPU,切换频繁了,这种情况下线程是最合适的。
这种原则最常见的是图像处理、算法处理。
3)强相关的处理用线程,弱相关的处理用进程
什么叫强相关、弱相关?理论上很难定义,给个简单的例子就明白了。
一般的Server需要完成如下任务:消息收发、消息处理。“消息收发”和“消息处理”就是弱相关的任务,而“消息处理”里面可能又分为“消息解码”、“业务处理”,这两个任务相对来说相关性就要强多了。因此“消息收发”和“消息处理”可以分进程设计,“消息解码”、“业务处理”可以分线程设计。
当然这种划分方式不是一成不变的,也可以根据实际情况进行调整。
4)可能要扩展到多机分布的用进程,多核分布的用线程
原因请看上面对比。
5)都满足需求的情况下,用你最熟悉、最拿手的方式
至于“数据共享、同步”、“编程、调试”、“可靠性”这几个维度的所谓的“复杂、简单”应该怎么取舍,我只能说:没有明确的选择方法。但我可以告诉你一个选择原则:如果多进程和多线程都能够满足要求,那么选择你最熟悉、最拿手的那个。
需要提醒的是:虽然我给了这么多的选择原则,但实际应用中基本上都是“进程+线程”的结合方式,千万不要真的陷入一种非此即彼的误区。
消耗资源:
从内核的观点看,进程的目的就是担当分配系统资源(CPU时间、内存等)的基本单位。线程是进程的一个执行流,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
线程,它们彼此之间使用相同的地址空间,共享大部分数据,启动一个线程所花费的空间远远小于启动一个进程所花费的空间,而且,线程间彼此切换所需的时间也远远小于进程间切换所需要的时间。据统计,总的说来,一个进程的开销大约是一个线程开销的30倍左右,当然,在具体的系统上,这个数据可能会有较大的区别。
通讯方式:
进程之间传递数据只能是通过通讯的方式,即费时又不方便。线程时间数据大部分共享(线程函数内部不共享),快捷方便。但是数据同步需要锁对于static变量尤其注意
线程自身优势:
提高应用程序响应;使多CPU系统更加有效。操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上;
改善程序结构。一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序会利于理解和修改。
实验数据:
进程实验代码(fork.c):
- #include <stdlib.h>
- #include <stdio.h>
- #include <signal.h>
- #define P_NUMBER 255 //并发进程数量
- #define COUNT 5 //每次进程打印字符串数
- #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);//向控制台输出
- /*当你频繁读写文件的时候,Linux内核为了提高读写性能与速度,会将文件在内存中进行缓存,这部分内存就是Cache Memory(缓存内存)。可能导致测试结果不准,所以在此注释*/
- //fprintf(logFile,"[%d]%s\n",j,s);//向日志文件输出,
- }
- exit(0);//子进程结束
- }
- }
-
- for(i=0;i<P_NUMBER;i++)//回收子进程
- {
- wait(0);
- }
-
- printf("Okay\n");
- return 0;
- }
进程实验代码(thread.c):
- #include <pthread.h>
- #include <unistd.h>
- #include <stdlib.h>
- #include <stdio.h>
- #define P_NUMBER 255//并发线程数量
- #define COUNT 5 //每线程打印字符串数
- #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);//想控制台输出
- /*当你频繁读写文件的时候,Linux内核为了提高读写性能与速度,会将文件在内存中进行缓存,这部分内存就是Cache Memory(缓存内存)。可能导致测试结果不准,所以在此注释*/
- //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("Okay\n");
- return 0;
- }
两段程序做的事情是一样的,都是创建“若干”个进程/线程,每个创建出的进程/线程打印“若干”条“hello linux”字符串到控制台和日志文件,两个“若干”由两个宏 P_NUMBER和COUNT分别定义,程序编译指令如下:
gcc -o fork fork.c
gcc -lpthread -o thread thread.c
实验通过time指令执行两个程序,抄录time输出的挂钟时间(real时间):
time ./fork
time ./thread
每批次的实验通过改动宏 P_NUMBER和COUNT来调整进程/线程数量和打印次数,每批次测试五轮,得到的结果如下:
一、重复周丽论文实验步骤
(注:本文平均值算法采用的是去掉一个最大值去掉一个最小值,然后平均)
单核(双核机器禁掉一核),进程/线程数:255,打印次数5 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 |
多进程 | 0m0.070s | 0m0.071s | 0m0.071s | 0m0.070s | 0m0.070s | 0m0.070s |
多线程 | 0m0.049s | 0m0.049s | 0m0.049s | 0m0.049s | 0m0.049s | 0m0.049s |
单核(双核机器禁掉一核),进程/线程数:255,打印次数10 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 |
多进程 | 0m0.112s | 0m0.101s | 0m0.100s | 0m0.085s | 0m0.121s | 0m0.104s |
多线程 | 0m0.097s | 0m0.089s | 0m0.090s | 0m0.104s | 0m0.080s | 0m0.092s |
单核(双核机器禁掉一核),进程/线程数:255,打印次数50 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 |
多进程 | 0m0.459s | 0m0.531s | 0m0.499s | 0m0.499s | 0m0.524s | 0m0.507s |
多线程 | 0m0.387s | 0m0.456s | 0m0.435s | 0m0.423s | 0m0.408s | 0m0.422s |
单核(双核机器禁掉一核),进程/线程数:255,打印次数100 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 |
多进程 | 0m1.141s | 0m0.992s | 0m1.134s | 0m1.027s | 0m0.965s | 0m1.051s |
多线程 | 0m0.925s | 0m0.899s | 0m0.961s | 0m0.934s | 0m0.853s | 0m0.919s |
单核(双核机器禁掉一核),进程/线程数:255,打印次数500 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 |
多进程 | 0m5.221s | 0m5.258s | 0m5.706s | 0m5.288s | 0m5.455s | 0m5.334s |
多线程 | 0m4.689s | 0m4.578s | 0m4.670s | 0m4.566s | 0m4.722s | 0m4.646s |
单核(双核机器禁掉一核),进程/线程数:255,打印次数1000 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 |
多进程 | 0m12.680s | 0m16.555s | 0m11.158s | 0m10.922s | 0m11.206s | 0m11.681s |
多线程 | 0m12.993s | 0m13.087s | 0m13.082s | 0m13.485s | 0m13.053s | 0m13.074s |
单核(双核机器禁掉一核),进程/线程数:255,打印次数5000 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 |
多进程 | 1m27.348s | 1m5.569s | 0m57.275s | 1m5.029s | 1m15.174s | 1m8.591s |
多线程 | 1m25.813s | 1m29.299s | 1m23.842s | 1m18.914s | 1m34.872s | 1m26.318s |
单核(双核机器禁掉一核),进程/线程数:255,打印次数10000 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 |
多进程 | 2m8.336s | 2m22.999s | 2m11.046s | 2m30.040s | 2m5.752s | 2m14.137s |
多线程 | 2m46.666s | 2m44.757s | 2m34.528s | 2m15.018s | 2m41.436s | 2m40.240s |
出的结果是: 任务量较大时,多进程比多线程效率高;而完成的任务量较小时,多线程比多进程要快,重复打印 600 次时,多进程与多线程所耗费的时间相同。
、增加每进程/线程的工作强度的实验
这次将程序打印数据增大,原来打印字符串为:
- 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";
单核(双核机器禁掉一核),进程/线程数:255 ,打印次数100 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 |
多进程 | 0m6.977s | 0m7.358s | 0m7.520s | 0m7.282s | 0m7.218s | 0m7.286 |
多线程 | 0m7.035s | 0m7.552s | 0m7.087s | 0m7.427s | 0m7.257s | 0m7.257 |
单核(双核机器禁掉一核),进程/线程数: 255,打印次数500 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 |
多进程 | 0m35.666s | 0m36.009s | 0m36.532s | 0m35.578s | 0m41.537s | 0m36.069 |
多线程 | 0m37.290s | 0m35.688s | 0m36.377s | 0m36.693s | 0m36.784s | 0m36.618 |
单核(双核机器禁掉一核),进程/线程数: 255,打印次数1000 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 |
多进程 | 1m8.864s | 1m11.056s | 1m10.273s | 1m12.317s | 1m20.193s | 1m11.215 |
多线程 | 1m11.949s | 1m13.088s | 1m12.291s | 1m9.677s | 1m12.210s | 1m12.150 |
【实验结论】
从上面的实验比对结果看,即使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):
- #include <stdio.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);
- }
- }
-
- printf("count:%d fount:%d scount:%d\n",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)
- {
- printf("Create pthread error!\n");
- return(1);
- }
- count++;
- pthread_join(id,NULL);
- }
-
- printf("count:%d\n",count);
- }
创建10万个线程的Java程序:
- public class ThreadTest
- {
- public static void main(String[] ags) throws InterruptedException
- {
- System.out.println("开始运行");
- long 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) + " 毫秒");
- }
- }
单核(双核机器禁掉一核),创建销毁10万个进程/线程 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均 |
多进程 | 0m8.774s | 0m8.780s | 0m8.475s | 0m8.592s | 0m8.687s | 0m8.684 |
多线程 | 0m0.663s | 0m0.660s | 0m0.662s | 0m0.660s | 0m0.661s | 0m0.661 |
创建销毁10万个线程(Java) |
---|
12286毫秒 |
从数据可以看出,多线程比多进程在效率上有10多倍的优势,但不能让我们在使用哪种并发模式上定性,这让我想起多年前政治课上的一个场景:在讲到优越性时,面对着几个对此发表质疑评论的调皮男生,我们的政治老师发表了高见,“不能只横向地和当今的发达国家比,你应该纵向地和过去中国几十年的发展历史 比”。政治老师的话套用在当前简直就是真理,我们看看,即使是在赛扬CPU上,创建、销毁进程/线程的速度都是空前的,可以说是有质的飞跃的,平均创建销毁一个进程的速度是0.18毫秒,对于当前服务器几百、几千的并发量,还有预先派生子进程/线程的必要吗?
预先派生子进程/线程比现场创建子进程/线程要复杂很多,不仅要对池中进程/线程数量进行动态管理,还要解决多进程/多线程对accept的“抢” 问题,在stevens的测试程序中,使用了“惊群”和“锁”技术。即使stevens的数据表格中,预先派生线程也不见得比现场创建线程快,在 《Unix网络编程》第三版中,新作者参照stevens的测试也提供了一组数据,在这组数据中,现场创建线程模式比预先派生线程模式已有了效率上的优势。因此我对这一节实验下的结论是:
预先派生进程/线程的模式(进程池、线程池)技术,不仅复杂,在效率上也无优势,在新的应用中可以放心大胆地为客户连接请求去现场创建进程和线程。
我想,这是fork迷们最愿意看到的结论了。
五、双核系统重复周丽论文实验步骤
双核,进程/线程数:255 ,打印次数10 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均(单核倍数) |
多进程 | 0m0.061s | 0m0.053s | 0m0.068s | 0m0.061s | 0m0.059s | 0m0.060(1.73) |
多线程 | 0m0.054s | 0m0.040s | 0m0.053s | 0m0.056s | 0m0.042s | 0m0.050(1.84) |
双核,进程/线程数: 255,打印次数100 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均(单核倍数) |
多进程 | 0m0.918s | 0m1.198s | 0m1.241s | 0m1.017s | 0m1.172s | 0m1.129(0.93) |
多线程 | 0m0.897s | 0m1.166s | 0m1.091s | 0m1.360s | 0m0.997s | 0m1.085(0.85) |
双核,进程/线程数: 255,打印次数1000 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均(单核倍数) |
多进程 | 0m11.276s | 0m11.269s | 0m11.218s | 0m10.919s | 0m11.201s | 0m11.229(1.04) |
多线程 | 0m11.525s | 0m11.984s | 0m11.715s | 0m11.433s | 0m10.966s | 0m11.558(1.13) |
双核,进程/线程数:255 ,打印次数10000 | ||||||
| 第1次 | 第2次 | 第3次 | 第4次 | 第5次 | 平均(单核倍数) |
多进程 | 1m54.328s | 1m54.748s | 1m54.807s | 1m55.950s | 1m57.655s | 1m55.168(1.16) |
多线程 | 2m3.021s | 1m57.611s | 1m59.139s | 1m58.297s | 1m57.258s | 1m58.349(1.35) |
【实验结论】
双核处理器在完成任务量较少时,没有系统其他瓶颈因素影响时基本上是单核的两倍,在任务量较多时,受系统其他瓶颈因素的影响,速度明显趋近于单核的速度。
六、并发服务的不可测性
看到这里,你会感觉到我有挺进程、贬线程的论调,实际上对于现实中的并发服务具有不可测性,前面的实验和结论只可做参考,而不可定性。
结束语
本篇文章比较了Linux系统上多线程和多进程的运行效率,在实际应用时还有其他因素的影响,比如网络通讯时采用长连接还是短连接,是否采用 select、poll,java中称为nio的机制,还有使用的编程语言,例如Java不能使用多进程,PHP不能使用多线程,这些都可能影响到并发模 式的选型。
最后还有两点提醒:
1. 文章中的所有实验数据有环境约束。
2. 由于并行服务的不可测性,文章中的观点应该只做参考,而不要去定性。