在Unix上编程选择多线程还是多进程颇具争议,尤其是在服务端并发技术的选择上,在WEB类服务器技术中,Nginx使用多线程(master信号作为父进程,fork()产生对应服务器CPU核数的子进程worker),Apache也是用多进程的(perfork模式,每客户连接对应一个进程,每进程中只存在唯一一个执行线程),Java的Web容器Tomcat等都是多线程的(每客户连接对应一个线程,所有线程都在一个进程中)。
一、定义区别
进程:
进程是程序执行时的一个实例。从内核的观点看,进程是拥有分配系统资源(CPU时间、内存等)的基本单位。
用fork()函数创建多线程:
static void sig_child( int signo ) {
pid_t pid;
int stat;
pid = wait(&stat);
printf( "child %d exit with stat: %d\n", pid ,stat);
return;
}
int main()
{
signal(SIGCHLD, &sig_child);
pid_t pid;
pid = fork();
if(pid < 0)
{
perror("fork error.");
exit(1);
}
else if(pid == 0)
{
printf("Child process is %d\n",getpid());
exit(0);
}
else
{
printf("Parent process: %d\n",getpid());
sleep(5);
}
return 0;
}
Fork()函数会生成一个父进程副本以及一个子进程,如if所示。该函数原理:子进程会复制父进程的task_struct结构,并为子进程的堆栈分配物理页。理论上来说,子进程拥有与父进程相同的完整的堆,栈以及数据空间,并且二者共享正文段(CPU执行的机器指令部分)。
关于写时复制:由于一般 fork后面都接着exec,所以,现在的 fork都在用写时复制的技术,顾名思意,就是,数据段,堆,栈,一开始并不复制,由父,子进程共享,并将这些内存设置为只读。直到父,子进程一方尝试写这些区域,则内核才为需要修改的那片内存拷贝副本。这样做可以提高 fork的效率。
注:函数sig_child用于解决僵尸进程(子进程退出,保存了进程信息,占用了进程ID,大量的zombie进程会导致进程号耗尽)。
线程:
线程是进程的一个执行流,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。一个进程由几个线程组成(拥有很多相对独立的执行流的用户程序共享应用程序的大部分数据结构),线程与同属一个进程的其他的线程共享进程所拥有的全部资源。
线程函数定义:
返回值0表示创建成功,其他值表示错误。
第一个参数为指向线程标识符的指针。
第二个参数用来设置线程属性。
第三个参数是线程运行函数的起始地址。
最后一个参数是运行函数的参数。
创建实例:
void* task1(void *arg1) //分离线程,完成自行回收
{
printf("task1\n");
pthread_exit( (void *)1);
}
void* task2(void *arg2) //非分离线程,通过阻塞函数 phread_join回收
{
int i=0;
printf("thread2 begin.\n");
pthread_exit((void *)2); //获取线程退出值赋给phread_join中的指针p
}
int main()
{
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);
return 0;
}
线程创建的默认属性为非分离状态,则主线程需要等待创建的线程结束,通过主线程调用pthread_join(),得到其返回值则意味着其创建线程的结束以及资源的回收;分离线程通过pthread_attr_setdetachstate设置,意味着线程的独立运行、退出和被回收,故该类线程不适用于非常快的任务,否则返回的线程号将指向其他分配了该ID的新线程。
注:linux下要编译使用线程的代码,一定要记得调用pthread库。如下编译:
gcc -o pthrea-pthread pthrea.c 或者在编译器的链接库中添加库。、
二、多任务操作的比较
貌似从操作系统的调度、并发性、系统开销等方面来说,线程的使用都要优于进程。
调度:
现行操作系统的CPU调度把线程作为基本分派单位,进程则作为资源拥有的基本单位。线程编程轻装运行,将显著地提高系统的并发性:统一进程中线程的切换不会引起进程切换,从而避免了昂贵的系统调用。
注:在由一个进程中的线程切换到另一进程中的线程,依然会引起线程切换。
并发性:
在引入线程的操作系统中,不仅进程之间可以并发执行,而且在一个进程中的多个进程之间也可以并发执行,因而使操作系统具有更好的并发性,从而更有效地是有系统资源和提高系统的吞吐量。例如,在一个为引入线程的单CPU操作系统中,若仅设置一个文件服务进程,当它由于某种原因被封锁时,便没有其他的文件服务进程来提供服务。在引入线程的操作系统中,可以在一个文件服务进程设置多个服务线程。当第一个线程等待时,文件服务进程中的第二个线程可以继续运行;当第二个线程封锁时,第三个线程可以继续执行,从而显著地提高了文件服务的质量以及系统的吞吐量。
系统开销:
由于在创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O设备等。因此,操作系统所付出的开销将显著地大于在创建或撤消线程时的开销。
上下文切换:
线程和进程的上下文切换最主要的区别是线程的切换虚拟内存空间是相同的,但进程是不同的,这两种切换都会将寄存器变量切换出去(线程私有寄存器变量),会有性能消耗。
另外进程中上下文切换会打乱处理器的缓存机制,一旦切换出去上下文,处理器中所有已经缓存的内存地址和数据等都作废了,尤其是进程切换会将整个虚拟内存空间切换出去,刷新了许多缓存,导致效率降低。但是在线程切换中就不会出现这个问题。
三、通信机制比较
进程通信有如下一些目的:
1. 数据传输:一个进程需要将它的数据发送给另一个进程,发送的数据量在一个字节到几M字节之间
2. 共享数据:多个进程想要操作共享数据,一个进程对共享数据的修改,别的进程应该立刻看到。
3. 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
4. 资源共享:多个进程之间共享同样的资源。为了作到这一点,需要内核提供锁和同步机制。
5. 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
注:几种IPC方式:管道、信号、报文队列、共享内存、信号量、套接字等。
线程之间通信的两个基本问题是互斥和同步。
线程同步是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。
线程互斥是指对于共享的操作系统资源,在各线程访问时的排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。
注:几种Unix同步互斥方法:互斥锁、信号量、读写锁、条件变量等。
总结:进程作为拥有资源的基本单位,其通信目的以及数据共享会很复杂,需要使用IPC,但是其数据独立,同步简单;线程则恰恰相反,同属于同一进程的线程其数据共享很简单,也正是这点则需要引入复杂严格的同步机制来规范各线程对于数据的访问,由此衍生出线程安全等问题和机制。
四、稳定性比较
之前于多任务比较时线程表现出良好的性能,但是低成本带来的是低性能。
多线程:每个线程与主程序共用地址空间,受限于2GB地址空间;线程之间的同步和加锁控制比较麻烦;一个线程的崩溃可能影响到整个程序的稳定性;到达一定的线程数程度后,即使再增加CPU也无法提高性能,例如Windows Server 2003,大约是1500个左右的线程数就快到极限了(线程堆栈设定为1M),如果设定线程堆栈为2M,还达不到1500个线程总数;线程能够提高的总性能有限,而且线程多了之后,线程本身的调度也是一个麻烦事儿,需要消耗较多的CPU。
多进程:每个进程互相独立,不影响主程序的稳定性,子进程崩溃没关系;通过增加CPU,就可以容易扩充性能;可以尽量减少线程加锁/解锁的影响,极大提高性能,就算是线程运行的模块算法效率低也没关系;每个子进程都有2GB地址空间和相关资源,总体能够达到的性能上限非常大。
引入论文《Linux系统下多线程与多进程性能分析》实例测试:
给出两端测试程序:(改变进程数和打印工作量)
进程代码:
#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;
}
#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;
}
根据其作者的测试数据及作者的结论:任务量较大时,多进程比多线程效率高;而完成的任务量较小时,多线程比多进程要快。
五、总结
文中表现有一定的倾向态度,只是为了说明在合适的条件下使用合适的编程选择才能带来最好的效果,实际的应用中“进程+线程”的取长补短才是王道。
本文探讨了Unix上编程时选择多线程还是多进程的争议,重点介绍了进程和线程的区别,包括定义、调度、并发性、系统开销和通信机制。同时,分析了多线程在轻量级并发、上下文切换和线程同步方面的优势,以及多进程在资源隔离和稳定性上的优点。最后,通过实例测试表明,任务量大小会影响多线程和多进程的效率选择,建议根据实际需求选取合适的并发模型。
406

被折叠的 条评论
为什么被折叠?



