Linux环境线程和多进程和多线程区别的区别到底是什么

1692人阅读
Linux C(382)
谈谈dpdk应用层包处理程序的多进程和多线程模型选择时的若干考虑
看到知乎上有个关于linux多进程、多线程的讨论:
自己项目里也对这个问题有过很多探讨和测试,所以正好开贴整理一下,题目有点长,其实就2点:
1. 多进程模型和多线程模型,这两种模型在linux上有什么区别,各有何优缺点?
& & 这里仅限于linux平台,因为linux平台跟win平台关于线程的实现差异很大。
2. 采用intel dpdk做包处理程序,是采用多进程模型好,还是多线程模型好?
 这里仅限于包处理程序(ips,waf,其他网络设备引擎),因为不同应用场景区别也很大。
首先知乎里边的评论,有个miao网友说的跟我的经验比较相符,先将其说法贴一下:
&linux使用的1:1的线程模型,在内核中是不区分线程和进程的,都是可运行的任务而已。fork调用clone(最少的共享),pthread_create也是调用clone(最大共享).fork创建会比pthread_create多消耗一点点,因为要拷贝tables和cow mapping.但是其实差别真的很细微,这些在内核开发者的努力下已经变的很小了。
再来说说contex switch的cost吧。线程的context switch是要比process小一些,因为线程共享了大部分的memory和tables,当switch的时候这些东西已经在缓存中了。
但是其实差别也很细微。但是在multiprocessor的系统中不共享memory其实是会比共享memory要有一点优势的,因为当任务在不同的processor中运行的时候,同步memory带来的损耗是不可忽视的。&
他这里说了两点有价值的信息,1 &linux里的线程实现决定,创建、调度、切换线程的开销跟进程相比,好不了多少。
              2 多核CPU下由于缓存命中率的问题,进程这种天生不共享内存的做法,实际上比线程这种天生共享内存
                的做法,从性能上是有好处的。
这两点见解跟我们项目实际测试和研究结果是相符合的。下面从几个方面探讨这些问题:
1 linux 线程创建方式
linux提供的线程实际上是核外线程,即主要的线程机制是通过应用层面的库pthread提供的(线程的id分配、线程创建和管理,据说基本实现是pthread库为每一个进程维护一个管理线程,单调用 pthread_create等posix API时,调用者与该管理线程通过管道传递命令),
核内层面,线程几乎可以等同于进程。 &这里贴一段从引用1&拷贝的内容:
Linux的线程实现是在核外进行的,核内提供的是创建进程的接口do_fork()。内核提供了两个系统调用__clone()和fork(),最终都用不同的参数调用do_fork()核内API。 do_fork() 提供了很多参数,包括CLONE_VM(共享内存空间)、CLONE_FS(共享文件系统信息)、CLONE_FILES(共享文件描述符表)、CLONE_SIGHAND(共享信号句柄表)和CLONE_PID(共享进程ID,仅对核内进程,即0号进程有效)。当使用fork系统调用产生多进程时,内核调用do_fork()不使用任何共享属性,进程拥有独立的运行环境。当使用pthread_create()来创建线程时,则最终设置了所有这些属性来调用__clone(),而这些参数又全部传给核内的do_fork(),从而创建的”进程”拥有共享的运行环境,只有栈是独立的,由
__clone()传入。
&&&&&&&&&即:Linux下不管是多线程编程还是多进程编程,最终都是用do_fork实现的多进程编程,只是进程创建时的参数不同,从而导致有不同的共享环境。Linux线程在核内是以轻量级进程的形式存在的,拥有独立的进程表项,而所有的创建、同步、删除等操作都在核外pthread库中进行。pthread
库使用一个管理线程(__pthread_manager() ,每个进程独立且唯一)来管理线程的创建和终止,为线程分配线程ID,发送线程相关的信号,而主线程pthread_create()) 的调用者则通过管道将请求信息传给管理线程。
上述内容基本可以这么表示:
  创建进程= fork ——& do_fork(不使用共享属性)
& & &&创建线程= pthread_create——&__clone ——& do_fork(共享地址空间(代码区、数据区)、页表、文件描述符、信号。。)
这里其实另外一种多进程创建方式,就是脚本直接启动多个进程
下面再贴一段:
“对于一个进程来说必须有的数据段、代码段、堆栈段是不是全盘复制呢?对于多进程来说,代码段是肯定不用复制的,因为父进程和各子进程的代码段是相同的,数据段和堆栈段呢?也不一定,因为在Linux里广泛使用的一个技术叫copy-on-write,即写时拷贝。copy-on-write意味着什么呢?意味着资源节省,假设有一个变量x在父进程里存在,当这个父进程创建一个子进程或多个子进程时这个变量x是否复制到了子进程的内存空间呢?不会的,子进程和父进程使用同一个内存空间的变量,但当子进程或父进程要改变变量x的值时就会复制该变量,从而导致父子进程里的变量值不同。”
这里我的理解是,刚fork完,子进程和父进程代码段、页表等还是共享的,接下去有两种可能发展方向,1是子进程修改了数据,这时候,代码段:仍然是共享的,不需要拷贝;堆和静态数据区: 根据copy-on-wirte机制,不改变值的地方仍然共享,改变值的地方需要重新申请物理页面并修改值,修改页表(可能还要拷贝页表);栈:&不管进程还是线程,都不能共享,都需要创建的时候分配栈区。2是fork之后马上调用
exec 用新的进程替换,这时候会载入新的代码段、数据段,构建新的页表。
对于我们的包处理系统而已,无论怎么启动,创建时的性能开销其实是无所谓的,因为都是在系统初始化的时候创建。
2 调度和切换
由于核内的线程本质就是进程,其调度过程跟进程一样。切换,不论是进程切换还是线程切换,都需要替换运行环境(内核堆栈,运行时寄存器等),对于内存的切换,内核部分内存是一样的,用户空间部分:如果是进程,需要替换页目录基址寄存器,如果是线程,不需要替换;总体而言,linux进程和线程的切换,从内存寄存器、内核堆栈寄存器、其他寄存器等的换值开销应该是差不多的。具体切换代码参考引用2
但是由于多线程共享地址空间,从一个线程切换到同一个进程上另一个线程运行,页表,数据区等很多都已经在内存甚至缓存里,而从一个进程切换到另一个进程,可能由于刚切换进来的进程的页面被虚拟内存管理模块替换出去导致的页面替换开销,另外还有缓存tlb失效导致的缓存更新开销,这里性能有所差别。
&对于我们的包处理系统而已,采用多核架构,主体进程/线程是绑定到不同的物理CPU core上并独占的,所以发生调度和切换的情况不多,因而这种影响不是很重要。
3. 地址空间共享相关问题
进程地址空间是独立的,这意味着,不同进程的内存天生就是不共享的,如果要共享,则需要开发者自己构建共享机制,比如使用IPC。
线程地址空间是共享的,这意味着,同一进程不同线程的内存天生是共享的,如果想要不共享,需要开发者自己实施,比如使用线程本地变量。
进程模型和线程模型,地址空间不共享和共享,会引发以下系列问题:
3.1 进程模型更安全、更健壮、更容易开发
由于一般公司成熟产品不是从无到有一个项目就开发完毕,必然有很多历史代码、多项目组合作的代码,这时候采用多进程模型,
可以有效隔离历史代码和当下代码、不同项目组的代码,当然,这需要产品本身是可以这么做的。比如,项目组A开发包处理进程,
项目组B开发包安全检测功能,两个功能是两个进程,这种模型无疑更容易开发和维护
另外,由于天生所以变量都不共享,对开发者要求也比开发多线程要低
3.2 多核下的性能
传统意义上,一般认为多线程比多进程性能要高,这其实是有前提的。比如不同线程之间需要频繁交互大量数据,由于IPC本身的开销,
如果数据交互非常频繁且量大,多线程会比多进程性能要高。
对于基于DPDK的多核数据包处理程序而言,由于3个原因,多进程模型更可预见性能高于多线程:
a DPDK提供了基于hugepage的共享内存机制,使得多进程物理地址相同,其虚拟地址也相同,这事实上就跟多线程之间共享地址空间是
一样的了。即采用DPDK的基础库,多进程之间不需要共享部分使用普通内存(libc malloc,静态区,栈区),相互隔离很安全。需要共享
部分采用dpdk hugepage 内存,通过特殊映射,也能共享虚拟地址。在这片共享内存上交互数据和指针(虚拟地址是一样的),性能
远高于利用内核的IPC机制。
b 多核缓存伪共享问题
这个问题在之前帖子里说过,多核架构一般有3层缓存,缓存命中率是系统整体性能最关键是因素之一。缓存命中率有一个致命杀手就是
伪共享现象,多线程由于天生所有内存全部是共享的,所以更容易发生伪共享现象,其任何变量,只要一个CPU核改了,其余CPU核都产生
一次缓存失效并重新加载。。,而多进程模型,共享部分是有限的且开发者可以精确设计和控制的,其伪共享现象可以得到有效控制。
在项目实际开发中,经常的情况就是多线程性能低于多进程,需要将很大变量改为线程局部变量,才能让性能有所提升。
c 同步互斥
其实,无论是多线程还是多进程,都需要面临同步和互斥,这个不是进程/线程模型决定的,而是业务模型决定的。dpdk 提供了应用层
空间实现的基础互斥同步接口,包括原子操作、自旋锁、读写锁等,主要是配合共享内存的访问,因为从数据包处理系统来说,基本上
没有阻塞的概念,所以这种原子操作和忙等待的锁可以满足大部分需求,对于需要阻塞的系统,比如应用层协议栈,则还是需要使用内核的
机制,比如信号量等
4 最终采用的模型
最终我们采用的模型是:主体框架是多进程,主进程内部有若干线程用于处理诸如命令接收、文件监控、配置同步、统计数据写出、
debug数据写出等功能,包处理的主体流程是多进程的,不同进程之间基础表项、数据包等数据采用dpdk共享内存,在系统启动时
静态映射好,这些关键的基础表项和数据包结构针对缓存做细致优化,比如对齐内存以避免发生伪共享。由于我们的业务同步和互斥方面
的要求不多,所以只使用了有限的忙等待的锁和原子操作函数。这种模型实际上也是intel 推荐的模型。当然,选择多进程模型后,
又有很多需要考虑的东西了,比如是流水线的worker1-worker2-worker3的多进程,还是 master-worker-worker-worker的对称多进程,这里头根据业务逻辑、同步互斥、性能、扩展性、可维护性有很多深入的考虑,这里就不详细说了。
鱼还是熊掌:浅谈多进程多线程的选择
关于多进程和多线程,教科书上最经典的一句话是“进程是资源分配的最小单位,线程是CPU调度的最小单位”,这句话应付考试基本上够了,但如果在工作中遇到类似的选择问题,那就没有这么简单了,选的不好,会让你深受其害。
经常在网络上看到有的XDJM问“多进程好还是多线程好?”、“Linux下用多进程还是多线程?”等等期望一劳永逸的问题,我只能说:没有最好,只有更好。根据实际情况来判断,哪个更加合适就是哪个好。
我们按照多个不同的维度,来看看多线程和多进程的对比(注:因为是感性的比较,因此都是相对的,不是说一个好得不得了,另外一个差的无法忍受)。
数据共享、同步
数据共享复杂,需要用IPC;数据是分开的,同步简单
因为共享进程数据,数据共享简单,但也是因为这个原因导致同步复杂
占用内存多,切换复杂,CPU利用率低
占用内存少,切换简单,CPU利用率高
创建销毁、切换
创建销毁、切换复杂,速度慢
创建销毁、切换简单,速度很快
编程、调试
编程简单,调试简单
编程复杂,调试复杂
进程间不会互相影响
一个线程挂掉将导致整个进程挂掉
适应于多核、多机分布式;如果一台机器不够,扩展到多台机器比较简单
适应于多核分布式
看起来比较简单,优势对比上是“线程&3.5 v 2.5&进程”,我们只管选线程就是了?
呵呵,有这么简单我就不用在这里浪费口舌了,还是那句话,没有绝对的好与坏,只有哪个更加合适的问题。我们来看实际应用中究竟如何判断更加合适。
1)需要频繁创建销毁的优先用线程
原因请看上面的对比。
这种原则最常见的应用就是Web服务器了,来一个连接建立一个线程,断了就销毁线程,要是用进程,创建和销毁的代价是很难承受的
2)需要进行大量计算的优先使用线程
所谓大量计算,当然就是要耗费很多CPU,切换频繁了,这种情况下线程是最合适的。
这种原则最常见的是图像处理、算法处理。
3)强相关的处理用线程,弱相关的处理用进程
什么叫强相关、弱相关?理论上很难定义,给个简单的例子就明白了。
一般的Server需要完成如下任务:消息收发、消息处理。“消息收发”和“消息处理”就是弱相关的任务,而“消息处理”里面可能又分为“消息解码”、“业务处理”,这两个任务相对来说相关性就要强多了。因此“消息收发”和“消息处理”可以分进程设计,“消息解码”、“业务处理”可以分线程设计。
当然这种划分方式不是一成不变的,也可以根据实际情况进行调整。
4)可能要扩展到多机分布的用进程,多核分布的用线程
原因请看上面对比。
5)都满足需求的情况下,用你最熟悉、最拿手的方式
至于“数据共享、同步”、“编程、调试”、“可靠性”这几个维度的所谓的“复杂、简单”应该怎么取舍,我只能说:没有明确的选择方法。但我可以告诉你一个选择原则:如果多进程和多线程都能够满足要求,那么选择你最熟悉、最拿手的那个。
需要提醒的是:虽然我给了这么多的选择原则,但实际应用中基本上都是“进程+线程”的结合方式,千万不要真的陷入一种非此即彼的误区。
1、进程与线程
进程是程序执行时的一个实例,即它是程序已经执行到课中程度的数据结构的汇集。从内核的观点看,进程的目的就是担当分配系统资源(CPU时间、内存等)的基本单位。
线程是进程的一个执行流,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。一个进程由几个线程组成(拥有很多相对独立的执行流的用户程序共享应用程序的大部分数据结构),线程与同属一个进程的其他的线程共享进程所拥有的全部资源。
&进程——资源分配的最小单位,线程——程序执行的最小单位&
进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。
总的来说就是:进程有独立的地址空间,线程没有单独的地址空间(同一进程内的线程共享进程的地址空间)。(下面的内容摘自)
使用多线程的理由之一是和进程相比,它是一种非常&节俭&的多任务操作方式。我们知道,在Linux系统下,启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维护它的代码段、堆栈段和数据段,这是一种&昂贵&的多任务工作方式。而运行于一个进程中的多个线程,它们彼此之间使用相同的地址空间,共享大部分数据,启动一个线程所花费的空间远远小于启动一个进程所花费的空间,而且,线程间彼此切换所需的时间也远远小于进程间切换所需要的时间。据统计,总的说来,一个进程的开销大约是一个线程开销的30倍左右,当然,在具体的系统上,这个数据可能会有较大的区别。
使用多线程的理由之二是线程间方便的通信机制。对不同进程来说,它们具有独立的数据空间,要进行数据的传递只能通过通信的方式进行,这种方式不仅费时,而且很不方便。线程则不然,由于同一进程下的线程之间共享数据空间,所以一个线程的数据可以直接为其它线程所用,这不仅快捷,而且方便。当然,数据的共享也带来其他一些问题,有的变量不能同时被两个线程所修改,有的子程序中声明为static的数据更有可能给多线程程序带来灾难性的打击,这些正是编写多线程程序时最需要注意的地方。
除了以上所说的优点外,不和进程比较,多线程程序作为一种多任务、并发的工作方式,当然有以下的优点:
提高应用程序响应。这对图形界面的程序尤其有意义,当一个操作耗时很长时,整个系统都会等待这个操作,此时程序不会响应键盘、鼠标、菜单的操作,而使用多线程技术,将耗时长的操作(time consuming)置于一个新的线程,可以避免这种尴尬的情况。使多CPU系统更加有效。操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上。改善程序结构。一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序会利于理解和修改。
在Unix上编程采用多线程还是多进程的争执由来已久,这种争执最常见到在B/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。我的环境是:双核 Intel(R) Xeon(R) CPU 5130& @ 2.00GHz(做实验时,禁掉了一核),512MG内存,Red Hat Enterprise Linux ES release 4 (Nahant Update 4),内核2.6.9-42。
进程实验代码(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
单核(双核机器禁掉一核),进程/线程数:255,打印次数10
单核(双核机器禁掉一核),进程/线程数:255,打印次数50
单核(双核机器禁掉一核),进程/线程数:255,打印次数100
单核(双核机器禁掉一核),进程/线程数:255,打印次数500
单核(双核机器禁掉一核),进程/线程数:255,打印次数1000
&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
&1m27.348s
0m57.275s&
1m15.174s&
&1m25.813s
1m23.842s&
1m18.914s&
1m34.872s&
1m26.318s&
单核(双核机器禁掉一核),进程/线程数:255,打印次数10000
2m22.999s&
2m11.046s&
2m30.040s&
2m14.137s&
&2m46.666s
2m44.757s&
2m34.528s&
2m15.018s&
2m41.436s&
2m40.240s&
本轮实验是为了和周丽论文作对比,因此将进程/线程数量限制在255个,论文也是测试了255个进程/线程分别进行5次,10 次,50 次,100 次,500 次……10000 次打印的用时,论文得出的结果是:任务量较大时,多进程比多线程效率高;而完成的任务量较小时,多线程比多进程要快,重复打印 600 次时,多进程与多线程所耗费的时间相同。
虽然我的实验直到1000打印次数时,多进程才开始领先,但考虑到使用的是NPTL线程库的缘故,从而可以证实了论文的观点。从我的实验数据看,多线程和多进程两组数据非常接近,考虑到数据的提取具有瞬间性,因此可以认为他们的速度是相同的。
是不是可以得出这样的结论:多线程创建、销毁速度快,而多线程切换速度快,这个结论我们会在第二个试验中继续试图验证
当前的网络环境中,我们更看中高并发、高负荷下的性能,纵观前面的实验步骤,最长的实验周期不过2分钟多一点,因此下面的实验将向两个方向延伸,第一,增加并发数量,第二,增加每进程/线程的工作强度。
二、增加并发数量的实验
下面的实验打印次数不变,而进程/线程数量逐渐增加。在实验过程中多线程程序在后四组(线程数350,500,800,1000)的测试中都出现了“段错误”,出现错误的原因和多线程预分配线程栈有关。
实验中的计算机CPU是32位,寻址最大范围是4GB(2的32次方),Linux是按照3GB/1GB的方式来分配内存,其中1GB属于所有进程共享的内核空间,3GB属于用户空间(进程虚拟内存空间)。Linux2.6的默认线程栈大小是8M(通过ulimit -a查看),对于多线程,在创建线程的时候系统会为每一个线程预分配线程栈地址空间,也就是8M的虚拟内存空间。线程数量太多时,线程栈累计的大小将超过进程虚拟内存空间大小(计算时需要排除程序文本、数据、共享库等占用的空间),这就是实验中出现的“段错误”的原因。
Linux2.6的默认线程栈大小可以通过 ulimit -s 命令查看或修改,我们可以计算出线程数的最大上线: (24*3) / () = 384,实际数字应该略小与384,因为还要计算程序文本、数据、共享库等占用的空间。在当今的稍显繁忙的WEB服务器上,突破384的并发访问并不是稀 罕的事情,要继续下面的实验需要将默认线程栈的大小减小,但这样做有一定的风险,比如线程中的函数分配了大量的自动变量或者函数涉及很深的栈帧(典型的是 递归调用),线程栈就可能不够用了。可以配合使用POSIX.1规定的两个线程属性guardsize和stackaddr来解决线程栈溢出问
题,guardsize控制着线程栈末尾之后的一篇内存区域,一旦线程栈在使用中溢出并到达了这片内存,程序可以捕获系统内核发出的告警信号,然后使用 malloc获取另外的内存,并通过stackaddr改变线程栈的位置,以获得额外的栈空间,这个动态扩展栈空间办法需要手工编程,而且非常麻烦。
有两种方法可以改变线程栈的大小,使用 ulimit -s 命令改变系统默认线程栈的大小,或者在代码中创建线程时通过pthread_attr_setstacksize函数改变栈尺寸,在实验中使用的是第一种,在程序运行前先执行ulimit指令将默认线程栈大小改为1M:
ulimit -s 1024
time ./thread
单核(双核机器禁掉一核),进程/线程数:100&,打印次数1000
单核(双核机器禁掉一核),进程/线程数:255&,打印次数1000
&0m10.046s
&0m10.111s
单核(双核机器禁掉一核),进程/线程数:350&&,打印次数1000
&0m13.773s
&0m13.500s
&0m13.519s
&0m13.474s
&0m13.351s
&0m12.754s
0m13.251s&
&0m12.813s
&0m16.861s
&0m12.764s
单核(双核机器禁掉一核),进程/线程数:&500&,打印次数1000
&0m23.762s
&0m22.151s
&0m23.926s
&0m21.327s
&0m21.429s
&0m20.603s
&0m20.291s
&0m21.654s
&0m20.684s
&0m20.671s
单核(双核机器禁掉一核),进程/线程数:800&&,打印次数1000
&0m33.616s
&0m31.757s
&0m31.759s
&0m32.232s
&0m32.498s
&0m32.050s
&0m32.787s
&0m33.055s
&0m32.902s
&0m32.235s
单核(双核机器禁掉一核),进程/线程数:&1000&,打印次数1000
&0m40.301s
&0m41.083s
&0m41.634s
&0m40.247s
&0m40.717s
&0m41.633s
&0m41.118s
&0m42.700s
&0m42.134s
&0m41.170s
【实验结论】&
当线程/进程逐渐增多时,执行相同任务时,线程所花费时间相对于进程有下降的趋势(本人怀疑后两组数据受系统其他瓶颈的影响),这是不是进一步验证了多线程创建、销毁速度快,而多进程切换速度快。
出现了线程栈的问题,让我特别关心Java线程是怎样处理的,因此用Java语言写了同样的实验程序,Java程序加载虚拟机环境比较耗时,所以没 有用time提取测试时间,而直接将测时写入代码。对Linux上的C编程不熟悉的Java程序员也可以用这个程序去对比理解上面的C语言试验程序。
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;&/*
每线程打印字符串次数 */
&&&&&&&&static&String&s&=&&hello linux\n&;
&&&&&&&&&&&
&&&&&&&&static&FileOutputStream&out&=&null;&/*
文件输出流 */
&&&&&&&&@Override
&&&&&&&&public&void&run()
&&&&&&&&&&&&for&(int&i&=&0;&i&&&COUNT;&i++)
&&&&&&&&&&&&{
&&&&&&&&&&&&&&&&System.out.printf(&[%d]%s&,&i,&s);&/*
向控制台输出 */
&&&&&&&&&&&&&&&
&&&&&&&&&&&&&&&&StringBuilder&sb&=&new&StringBuilder(16);
&&&&&&&&&&&&&&&&sb.append(&[&).append(i).append(&]&).append(s);
&&&&&&&&&&&&&&&&try
&&&&&&&&&&&&&&&&{
&&&&&&&&&&&&&&&&&&&&out.write(sb.toString().getBytes());/*
向日志文件输出 */
&&&&&&&&&&&&&&&&}
&&&&&&&&&&&&&&&&catch&(IOException&e)
&&&&&&&&&&&&&&&&{
&&&&&&&&&&&&&&&&&&&&e.printStackTrace();
&&&&&&&&&&&&&&&&}
&&&&&&&&&&&&}
&&&&&&&&public&static&void&main(String[]&args)&throws&FileNotFoundException,&InterruptedException
&&&&&&&&&&&&MyThread[]&threads&=&new&MyThread[P_NUMBER];&/*
线程数组 */
&&&&&&&&&&&
&&&&&&&&&&&&File&file&=&new&File(&Javalogfile.log&);
&&&&&&&&&&&&out&=&new&FileOutputStream(file,&true);&/*
日志文件输出流 */
&&&&&&&&&&&
&&&&&&&&&&&&System.out.println(&开始运行&);
&&&&&&&&&&&&long&start&=&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();
&&&&&&&&&&&&}
&&&&&&&&&&&
&&&&&&&&&&&&System.out.println(&用时:&&+&(System.currentTimeMillis()&–&start)&+&&
&&&&&&&&&&&&return;
进程/线程数:1000&&,打印次数1000(用得原作者的数据)
Java程序比C程序慢一些在情理之中,但Java程序并没有出现线程栈问题,5次测试都平稳完成,可以用下面的ps指令获得java进程中线程的数量:
diaoyf@ali:~$ ps -eLf | grep MyThread | wc -l
用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服务器了),仍具有很好的线性。
三、增加每进程/线程的工作强度的实验
这次将程序打印数据增大,原来打印字符串为:
char&*s =&&hello linux\0&;
现在修改为每次打印256个字节数据:
char&*s&=&&abcdef\
&&&&abcdef\
&&&&abcdef\
&&&&abcdef\
&&&&abcdef\
&&&&abcdef\
&&&&abcdef\
&&&&abcdef\
&&&&abcdef\
&&&&abcdef\
&&&&abcdef\
&&&&abcdef\
&&&&abcdef\
&&&&abcdef\
&&&&abcdef\
&&&&abcdef\0&;
单核(双核机器禁掉一核),进程/线程数:255&&,打印次数100
单核(双核机器禁掉一核),进程/线程数:&&255,打印次数500
&0m35.666s
&0m36.009s
&0m36.532s
&0m35.578s
&0m41.537s
&0m37.290s
&0m35.688s
&0m36.377s
&0m36.693s
&0m36.784s
单核(双核机器禁掉一核),进程/线程数:&255,打印次数1000
&1m11.056s
&1m10.273s
&1m12.317s
&1m20.193s
&1m11.949s
&1m13.088s
&1m12.291s
&1m12.210s
【实验结论】
从上面的实验比对结果看,即使Linux2.6使用了新的NPTL线程库(据说比原线程库性能提高了很多,唉,又是据说!),多线程比较多进程在效率上没有任何的优势,在线程数增大时多线程程序还出现了运行错误,实验可以得出下面的结论:
在Linux2.6上,多线程并不比多进程速度快,考虑到线程栈的问题,多进程在并发上有优势。
四、多进程和多线程在创建和销毁上的效率比较
预先创建进程或线程可以节省进程或线程的创建、销毁时间,在实际的应用中很多程序使用了这样的策略,比如Apapche预先创建进程、Tomcat 预先创建线程,通常叫做进程池或线程池。在大部分人的概念中,进程或线程的创建、销毁是比较耗时的,在stevesn的著作《Unix网络编程》中有这样 的对比图(第一卷 第三版 30章 客户/服务器程序设计范式):
服务器描述
进程控制CPU时间(秒,与基准之差)
Solaris2.5.1
Digital Unix4.0b
迭代服务器(基准测试,无进程控制)
简单并发服务,为每个客户请求fork一个进程
预先派生子进程,每个子进程调用accept
预先派生子进程,用文件锁保护accept
预先派生子进程,用线程互斥锁保护accept
预先派生子进程,由父进程向子进程传递套接字
并发服务,为每个客户请求创建一个线程
预先创建线程,用互斥锁保护accept
预先创建线程,由主线程调用accept
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,也可能是 : ,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万个进程/线程
创建销毁10万个线程(Java)
从数据可以看出,多线程比多进程在效率上有10多倍的优势,但不能让我们在使用哪种并发模式上定性,这让我想起多年前政治课上的一个场景:在讲到优越性时,面对着几个对此发表质疑评论的调皮男生,我们的政治老师发表了高见,“不能只横向地和当今的发达国家比,你应该纵向地和过去中国几十年的发展历史 比”。政治老师的话套用在当前简直就是真理,我们看看,即使是在赛扬CPU上,创建、销毁进程/线程的速度都是空前的,可以说是有质的飞跃的,平均创建销毁一个进程的速度是0.18毫秒,对于当前服务器几百、几千的并发量,还有预先派生子进程/线程的必要吗?
预先派生子进程/线程比现场创建子进程/线程要复杂很多,不仅要对池中进程/线程数量进行动态管理,还要解决多进程/多线程对accept的“抢” 问题,在stevens的测试程序中,使用了“惊群”和“锁”技术。即使stevens的数据表格中,预先派生线程也不见得比现场创建线程快,在 《Unix网络编程》第三版中,新作者参照stevens的测试也提供了一组数据,在这组数据中,现场创建线程模式比预先派生线程模式已有了效率上的优势。因此我对这一节实验下的结论是:
预先派生进程/线程的模式(进程池、线程池)技术,不仅复杂,在效率上也无优势,在新的应用中可以放心大胆地为客户连接请求去现场创建进程和线程。
我想,这是fork迷们最愿意看到的结论了。
五、双核系统重复周丽论文实验步骤
双核,进程/线程数:255&,打印次数10
平均(单核倍数)
&0m0.060(1.73)
&0m0.050(1.84)
双核,进程/线程数:&255,打印次数100
平均(单核倍数)
&0m1.129(0.93)
&0m1.085(0.85)
双核,进程/线程数:&255,打印次数1000
平均(单核倍数)
0m11.269s&
&0m11.229(1.04)
&0m11.558(1.13)
双核,进程/线程数:255&,打印次数10000
平均(单核倍数)
&1m55.168(1.16)
1m59.139s&
1m57.258s&
&1m58.349(1.35)
【实验结论】
双核处理器在完成任务量较少时,没有系统其他瓶颈因素影响时基本上是单核的两倍,在任务量较多时,受系统其他瓶颈因素的影响,速度明显趋近于单核的速度。
六、并发服务的不可测性
看到这里,你会感觉到我有挺进程、贬线程的论调,实际上对于现实中的并发服务具有不可测性,前面的实验和结论只可做参考,而不可定性。对于不可测性,我举个生活中的例子。
这几年在大都市生活的朋友都感觉城市交通状况越来越差,到处堵车,从好的方面想这不正反应了我国GDP的高速发展。如果你7、8年前来到西安市,穿 过南二环上的一些十字路口时,会发现一个奇怪的U型弯的交通管制,为了更好的说明,我画了两张图来说明,第一张图是采用U型弯之前的,第二张是采用U型弯 之后的。
南二环交通图一
南二环交通图二
为了讲述的方便,我们不考虑十字路口左拐的情况,在图一中东西向和南北向的车辆交汇在十字路口,用红绿灯控制同一时间只能东西向或南北向通行,一般 的十字路口都是这样管控的。随着车辆的增多,十字路口的堵塞越来越严重,尤其是上下班时间经常出现堵死现象。于是交通部门在不动用过多经费的情况下而采用 了图二的交通管制,东西向车辆行进方式不变,而南北向车辆不能直行,需要右拐到下一个路口拐一个超大的U型弯,这样的措施避免了因车辆交错而引发堵死的次 数,从而提高了车辆的通过效率。我曾经问一个每天上下班乘公交经过此路口的同事,他说这样的改动不一定每次上下班时间都能缩短,但上班时间有保障了,从而
迟到次数减少了。如果今天你去西安市的南二环已经见不到U型弯了,东西向建设了高架桥,车辆分流后下层的十字路口已恢复为图一方式。
从效率的角度分析,在图一中等一个红灯45秒,远远小于图二拐那个U型弯用去的时间,但实际情况正好相反。我们可以设想一下,如果路上的所有运行车 辆都是同一型号(比如说全是QQ3微型车),所有的司机都遵守交规,具有同样的心情和性格,那么图一的通行效率肯定比图二高。现实中就不一样了,首先车辆 不统一,有大车、小车、快车、慢车,其次司机的品行不一,有特别遵守交规的,有想耍点小聪明的,有性子慢的,也有的性子急,时不时还有三轮摩托逆行一下, 十字路口的“死锁”也就难免了。
那么在什么情况下图二优于图一,是否能拿出一个科学分析数据来呢?以现在的科学技术水平是拿不出来的,就像长期的天气预报不可预测一样,西安市的交管部门肯定不是分析各种车辆的运行规律、速度,再进行复杂的社会学、心理学分析做出U型弯的决定的,这就是要说的不可测性。
现实中的程序亦然如此,比如WEB服务器,有的客户在快车道(宽带),有的在慢车道(窄带),有的性子慢(等待半分钟也无所谓),有的性子急(拼命 的进行浏览器刷新),时不时还有一两个黑客混入其中,这种情况每个服务器都不一样,既是是同一服务器每时每刻的变化也不一样,因此说不具有可测性。开发者 和维护者能做的,不论是前面的这种实验测试,还是对具体网站进行的压力测试,最多也就能模拟相当于QQ3通过十字路口的场景。
本篇文章比较了Linux系统上多线程和多进程的运行效率,在实际应用时还有其他因素的影响,比如网络通讯时采用长连接还是短连接,是否采用 select、poll,java中称为nio的机制,还有使用的编程语言,例如Java不能使用多进程,PHP不能使用多线程,这些都可能影响到并发模 式的选型。
最后还有两点提醒:
1. 文章中的所有实验数据有环境约束。
2. 由于并行服务的不可测性,文章中的观点应该只做参考,而不要去定性。
【参考资料】
1. 《Linux系统下多线程与多进程性能分析》作者“周丽 焦程波 兰巨龙”,这是我写这篇文章的诱因之一,只是不知道引用原作的程序代码是否属于侵权行为。
2. stevens著作的《Unix网络编程(第一卷)》和《Unix高级环境编程》,这两本书应该收集入IT的四书五经。
3. Robert Love著作的《Linux内核设计与实现》。
4. John Fusco 著作的《Linux开发工具箱》,这本书不太出名,但却是我读过的对内存和进程调度讲解最清晰明了的,第5章“开发者必备内核知识”和第6章“进程”是这本书的精华。
&&相关文章推荐
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
访问:295068次
积分:8930
积分:8930
排名:第2088名
原创:638篇
转载:46篇
评论:12条
(46)(25)(18)(78)(88)(5)(12)(72)(123)(219)

我要回帖

更多关于 进程和线程的区别 的文章

 

随机推荐