并发编程
概述
如果逻辑控制流在时间上重叠,那么它们就是并发的(concurrent),这种常见的现象称为并发(concurrency)。并发出现在计算机系统的各个层面上——硬件异常处理程序、进程和信号处理程序。以上提及的都在内核层面上的并发,其实并发不仅仅局限于内核,它也在应用程序中扮演重要的角色:
- 访问慢速I/O设备:当一个应用程序正在等待来自慢速I/O设备(比如磁盘)的数据到来时,内核会运行其他进程,使CPU保持繁忙。每个应用都可以按照类似的方式,通过交替执行I/O请求和其他有用的工作来使用并发。
- 与人交互:和计算机交互的人要求计算机有同时执行多个任务的能力。
- 通过推迟工作以降低延迟
- 服务多个网络客户端:大多数的服务器能够同时为多个客户提供服务,这也是通过并发实现的。
- 在多核机器上进行并行计算:被划分成并发流的应用程序通常在多核机器上比在单处理器机器上运行得快,因为这些流会并行执行,而不是交错运行。
使用应用级并发的应用程序被称为并发程序(concurrent program),现代操作系统提供了三种基本的构造并发程序的方法:
- 进程:每个逻辑控制流都是一个进程,由内核调度和维护。由于进程有独立的虚拟地址空间,一个进程想要和其他进程通信,控制流必须使用某种显式的进程间通信(interprocess communication,IPC)机制。
- I/O多路复用:应用程序在一个进程的上下文中显式地调用它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。
- 线程:线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。可以将线程看成上面两种方式的混合体——像进程流一样由内核进行调度,像I/O多路复用流一样共享同一个地址空间。
基于进程的并发编程
构造并发程序最简单的方法就是使用进程,使用fork
、exec
、waitpid
的函数。例如,一个构造并发服务器的方法是:在父进程中接受客户端连接请求,然后创建一个新的子进程来为每个新客户提供服务。
假设有两个客户端和一个服务器,服务器正在监听一个监听描述符(比如描述符3)上的请求。现在加入服务器接受了客户端1的连接请求,并返回一个已连接描述符(比如描述符4),如下图所示:
在接受连接请求之后,服务器派生出一个子进程,这个子进程获得服务器描述符表的完整拷贝。子进程关闭它的监听描述符3,而父进程关闭它的已连接描述符4。现在父子进程的状态如下:
因为父子进程中的已连接描述符都指向同一个文件表表项,对应的文件表条目引用计数为2,所以父进程关闭它的已连接描述符的拷贝是至关重要的,否则将永远不会释放已连接描述符4的文件表条目,而且由此引起的存储器泄漏将最终耗尽可用的存储器,使系统崩溃。
基于进程的并发服务器
下面展示一个基于进程的并发服务器模型,这个服务器代码比较简单,不过有几点值得注意:
- 首先,通常服务器会运行很长时间
void sigchld_handler(int sig)
{
// 尽可能多地回收僵死进程
while(waitpid(-1, 0, WNOHANG) > 0)
;
return ;
}
- 其次,父子进程必须关闭各自的已连接描述符的拷贝
- 最后,因为套接字的文件表表项中的引用计数,直到父子进程额已连接描述符都关闭了,到客户端的连接才会终止。
下面是服务器的主要代码:
typedef struct sockaddr SA;
int main()
{
int listenfd, connfd, port;
socklen_t clientlen = sizeof(struct sockaddr_in);
struct sockaddr_in clientaddr;
// 初始化工作
...
// 安装回收僵死子进程的信号处理程序
signal(SIGCHLD, sigchld_handler);
// 创建监听描述符
listenfd = create_listenfd(port);
while(1) {
// 等待客户端连接
connfd = accept(listenfd, (SA*)&clientaddr, &clientlen);
if(0 == fork()) {
// 子进程为客户端提供服务
close(listenfd); // 关闭子进程的监听描述符拷贝
provide_service(connfd); // 提供服务
close(connfd); // 关闭子进程的已连接描述符拷贝
exit(0);
}
// 关闭父进程的已连接描述符的拷贝
close(connfd);
}
}
进程的优劣
从上面的基于进程的并发服务器,可以看到进程有一个非常清晰的模型:共享文件表,但不共享用户地址空间。由于进程有独立的地址空间,这样既有优点也有缺点:
- 优点:一个进程不可能不小心覆盖另一个进程的虚拟存储器,这消除了许多错误。
- 缺点:进程间为了共享信息,必须使用显式的IPC机制。并且这种基于进程的并发服务器通常比较慢,因为进程控制和IPC的开销很高。
基于I/O复用的并发编程
假设我们的服务器能够监听客户端的连接请求,并且能够响应本地终端输入的交互命令,这种情况下,服务器需要响应两个相互独立的I/O事件:
- 网络客户端发起的连接请求
- 用户在终端输入的命令行
这时候问题来了,当accept阻塞等待连接请求时,我们无法响应用户输入的命令;当read阻塞等待用户输入命令时,我们无法响应任何连接请求。
针对这种无法同时处理多个独立I/O事件的情况,有一种解决方案——利用I/O多路复用
技术。我们可以通过select函数
,要求内核挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序。关于I/O多路复用
和select函数
的使用请见我的另一篇博客:url
基于I/O多路复用的并发服务器
下面,使用select函数构建一个基于I/O多路复用的并发服务器(忽略出错处理等细节),尝试解决上面的问题:
void provide_service(int connfd); // 为客户端提供服务
void command(void); // 从终端读入命令行
int main(int argc, char **argv)
{
int listenfd, connfd, port;
socklen_t clientlen = sizeof(struct sockaddr_in);
struct sockaddr_in clientaddr;
fd_set read_set;
fd_set ready_set;
// 监听描述符的初始化工作
.......
// 设置读集合
FD_ZERO(&read_set);
FD_SET(STDIN_FILENO, &read_set);
FD_SET(listenfd, &read_set);
while(1) {
ready_set = read_set;
select(listenfd + 1, &ready_set, NULL, NULL, NULL);
// 标准输入准备好读
if(FD_ISSET(STDIN_FILENO, &ready_set))
command();
// 有新的连接请求
if(FD_ISSET(listenfd, &ready_set)) {
connfd = accept(listenfd, (SA*)&clientaddr, &clientlen); // 接收连接请求
provide_service(connfd); // 提供服务
close(connfd); // 关闭连接描述符
}
}
}
void command(void)
{
char buf[1024];
// EOF
if(!fgets(buf, 1024, stdin))
exit(0);
// 对读取到的命令进行响应
......
}
上面的模型比较简单,我们监听的读集合中包含标准输入流和监听描述符,调用select函数将阻塞,直到标准输入流准备好读或接收到连接请求时才返回:
- 如果标准输入准备好读了,则调用command函数响应输入的命令行
- 如果监听描述符准备好读了,则调用accept函数获取到一个连接描述符,然后为对应的客户端提供服务,最后关闭该描述符。
基于线程的并发编程
线程(thread)是运行在进程上下文中的逻辑流,它由内核调度。每个线程都有自己的线程上下文(thread context),包括一个唯一的整数线程ID(Thread ID,TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有运行在一个进程里的程序共享该进程的整个虚拟地址空间。
基于线程的逻辑流结合了基于进程和基于I/O多路复用的流的特性。同进程一样,线程由内核调度,并且内核通过TID来识别线程。同基于I/O多路复用的流一样,多个线程运行在单一的进程上下文中,因此共享这个进程虚拟地址空间的整个内容,包括它的代码、数据、堆、共享库和打开的文件。
在一些方面,多线程的执行类似于多进程模型的执行。每个进程开始生命周期时都是单一线程,这个线程称为主线程(main thread)。在某一时刻,主线程创建一个对等线程(peer thread),从这个时间点开始,两个线程就并发地进行。最后,因为主线程执行一个慢速系统调用,如read
或sleep
,或者因为它被系统的间隔计时器中断,控制就会通过上下文切换传送到对等线程。对等线程会执行一段时间,然后控制传递回主线程,以此类推。
在另一些方面,线程的执行又不同于进程:
- 一个线程的上下文比进程的上下文小很多,因此线程的上下文切换要比进程的上下文切换快得多。
- 线程不像进程那样按照严格的父子层次来组织。和一个进程相关的线程组成一个对等线程池(pool),独立于其他进程的线程。主线程和其他线程的区别仅仅在于它总是进程中第一个运行的线程。对等线程池的主要影响是:一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。此外,每个对等线程都能读写相同的共享数据。
基于Posix线程的并发服务器
Posix线程(Pthreads)是在C程序中处理线程的一个标准接口,它定义了大约60个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。
对Posix线程的使用请见我的另一篇博客:Posix线程. .包含了Posix常用函数的介绍,以及对读写者问题、生产者消费者问题的讨论。
下面让我们来看看一个基于线程的并发服务器,其结构类似于基于进程模型的设计,主线程不断地等待连接请求,然后创建一个对等线程处理请求。下面给出主要代码(忽略错误处理等细节):
void *thread(void *vargp);
int main(int argc, char **argv)
{
int listenfd, port;
int *connfdp;
socklen_t clientlen = sizeof(struct sockaddr_in);
struct sockaddr_in clientaddr;
pthread_t tid;
// 初始化工作
...
listenfd = create_listenfd(port);
while(1) {
connfdp = malloc(sizeof(int)); // 为每一个已连接描述符动态分配内存
*connfdp = accept(listenfd, (SA*)&clientaddr, &clientlen);
pthread_create(&tid, NULL, thread, connfdp); // 为每一个客户连接创建一个对等线程,提供服务
}
}
void *thread(void *vargp)
{
int connfd = *((int*)vargp); // 获取已连接描述符
pthread_detach(pthread_self()); // 分离每个线程,使其资源在本线程终止时由系统回收
free(vargp); // 释放主线程分配的内存
provide_service(connfd); // 为客户提供服务
close(connfd); // 关闭已连接描述符
return NULL;
}
上面的代码比较简单,但是有几个点需要注意:
(1) 在创建对等线程时,我们将已连接描述符作为线程例程的参数,但我们不是通过下面这种方式:
int connfd;
...
while(1) {
connfd = accept(listenfd, (SA*)&clientaddr, &clientlen);
pthread_create(&tid, NULL, thread, &connfd); // 传递一个指向已连接描述符的指针
}
上面这种方式可能会出错,因为它在对等线程的第一条赋值语句和主线程的accept语句间引入了竞争:
- 如果赋值语句在下一个accept之前完成,那么对等线程中的局部变量connfd能够得到正确的描述符值。
- 如果赋值语句在下一个accept之后才完成,那么对等线程中的局部变量connfd将得到下一次连接的描述符值。
为了潜在的竞争,我们为每一个已连接描述符分配它自己的动态分配存储器块!
(2) 在线程例程中要回收主线程分配的内存块,避免发生内存泄漏
(3) 不同于基于进程的服务器,我们只在线程例程thread中关闭已连接描述符。这是因为线程运行在同一个进程中,它们共享相同的描述符表,无论多少个线程使用这个已连接描述符,它的文件表条目的引用计数都等于1,因此只需在线程例程中执行一次close即可关闭已连接描述符。(如对文件描述符有疑问,可以见我的另一篇博客:系统级I/O)
基于线程池的并发服务器
上面基于Posix线程的并发服务器中,我们为每个新客户的请求创建一个新的线程。这种方法的缺点比较明显,每次都要为一个新客户创建一个新线程,有比较大的开销。
其实,可以预先创建一些工作线程,然后主线程不断接受来自客户端的连接请求,并将得到的连接描述符放在一个有限的缓冲区中。而工作线程则反复地从连接描述符缓冲区中取得描述符,然后为客户端服务,服务完成后,再次等待下一个描述符。
仔细观察,上面这种模型本质上是一种生产者-消费者模型——主线程作为生产者,见连接描述符放到缓冲区中;工作线程为消费者,从缓冲区获取描述符,为客户端进行服务。由于工作线程是预先创建的,因此不需要在每一个新连接到来时都为其创建一个线程,从而减少创建线程的开销。这种基于线池程池的并发服务器结构如下图所示:
下面以一个基于线程池的并发回显服务器作为例子(忽略错误处理等细节):
#include <pthread.h>
#include <socket.h>
#include "PV.h"
#include "sbuf.h"
#define NPTHREADS 4
#define MAXBUFSIZE 16
typedef struct sockaddr SA;
void *thread(void *vargp);
sbuf_t buf; // 连接描述符的缓冲区
int main(int argc, char **argv)
{
int i, listenfd, connfd, port;
socklen_t client_len = sizeof(struct sockaddr_in);
struct sockaddr_in clientaddr;
pthread_t tid;
// socket套接字的初始化工作
.....
// 初始化连接描述符的缓冲区
sbuf_init(&buf, MAXBUFSIZE);
// 创建NPTHREADS个线程
for(i = 0; i<NTHREADS; ++i)
pthread_create(&tid, NULL, thread, NULL);
while(1) {
// 主线程接收客户端的连接请求
connfd = accept(listenfd, (SA*)&clientaddr, &clientlen);
// 将连接描述符放入缓冲区中
buf_insert(&sbuf, connfd);
}
}
// 工作线程
void *thread(void *vargp)
{
pthread_detach(pthread_self()); // 分离每个线程,使其资源在本线程终止时由系统回收
// 工作线程进入无限循环,从缓冲区中获取连接描述符,进行服务
while(1) {
// 从连接描述符缓冲区中获取连接描述符
int connfd = buf_remove(&sbuf);
// 提供服务
provide_service(connfd);
// 服务完成,关闭连接描述符
close(connfd);
}
}
为了简化对缓冲区的操作,我们封装了一个简单的包,构建生产者-消费者对缓冲区的操作:
// sbuf.h
typedef struct{
int *buf; /*缓冲区*/
int n; /*缓冲区槽的数目*/
int front; /*front%n是第一个非空闲的槽*/
int rear; /*rear%n是最后一个非空闲的槽*/
sem_t mutex; /*初始化为1,提供互斥的缓冲区访问*/
sem_t slots; /*初始化为n,记录空槽的信号量*/
sem_t items; /*初始化为0,记录非空槽数量的信号量*/
}sbuf_t;
// 初始化
void sbuf_init(sbuf_t *sp, int n)
{
sp->buf = calloc(n, sizeof(int)); // 创建缓冲区,并将其内容初始化为0
sp->n = n;
sp->front = sp->rear = 0;
sem_init(&sp->mutex, 0, 1);
sem_init(&sp->slots, 0, n);
sem_init(&sp->items, 0, 0);
}
// 释放缓冲区
void sbuf_deinit(sbuf_t *sp)
{
free(sp->buf);
}
// 将新产品插入到缓冲区中的第一个空闲槽中
void sbuf_insert(sbuf_t *sp, int item)
{
P(&sp->slots); // 等待空槽
P(&sp->mutex); // 访问共享缓冲区前需要获得mutex锁
sp->rear++;
buf[sp->rear % sp->n] = item; // 将产品放入空槽中
V(&sp->mutex); // 释放mutex锁
V(&sp->items); // 宣布有新产品可用
}
// 从缓冲区中的第一个非空闲槽获取产品
int sbuf_remove(sbuf_t *sp)
{
int item;
P(&sp->items); // 等待新产品
P(&sp->mutex); // 访问共享缓冲区前需要获得mutex锁
item = buf[sp->front % sp->n];// 获得产品
sp->front++;
V(&sp->mutex); // 释放mutex锁
V(&sp->slots); // 宣布有空槽可用
return item;
}
下面是封装了sem_wait
的P操作,和封装了sem_post
的V操作:
// PV.h
#include <semaphore.h>
#include <stdio.h>
#include <stdlib.h>
/* *nix风格的错误报告函数 */
void unix_error(char *msg)
{
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
exit(0);
}
/* P操作:包装sem_wait函数 */
void P(sem_t *s)
{
if (sem_wait(sem) < 0)
unix_error("P error");
}
/* V操作:包装sem_post函数 */
void V(sem_t *s)
{
if (sem_post(sem) < 0)
unix_error("V error");
}
以上便是基于线程池的并发服务器模型:
- 在主线程中,我们预先创建了4个工作线程,用于处理客户端请求,然后主线程进入一个无限循环,接收客户端的连接请求,并将连接描述符插入到连接描述符缓冲区中。
- 在每个工作进程中,我们首先分离工作线程,使其资源在该线程终止的时候有操作系统回收;然后工作线程进入一个无限循环,重复同样的工作——从连接描述符缓冲区中获取连接描述符,为对应的客户端提供服务,完成后关闭连接描述符,接着再次等待下一个描述符。
- 整个框架基于
线程池
,同时采用生产者-消费者模型
实现对连接描述符缓冲区的操作。主线程相当于生产者,将获取到的连接描述符放进缓冲区;每个工作线程都是一个消费者,从缓冲区获取连接描述符。这样减少了为每个客户端连接创建一个线程的开销,但是引入了对缓冲区的同步操作问题。
小结
我们在上面讨论了三种并发编程的模型:
- 在基于进程的并发模型中,我们为每个并发流使用了单独的进程。内核会自动调度每个进程,每个进程都有各自的地址空间,这使得进程地址空间不容易被其他进程破坏,但是也使得并发流之间共享数据很困难,需要通过IPC机制实现数据共享。
- 在基于I/O多路复用的并发模型中,我们创建自己的并发流,并利用I/O多路复用显式地调度流,由于只有一个进程,所有并发流共享整个地址空间。
- 在基于线程的并发模型中,我们结合了基于进程和基于I/O多路复用的流的特性——线程能够像进程一样有内核调度,也能够像I/O多路复用模型一样共享进程的部分地址空间,并且提供了一个简单的线程并发服务器模型以及一个基于线程池的并发服务器模型。
参考资料
- Randal E.Bryant, DavidR.O’Hallaron, 布赖恩特,等. 深入理解计算机系统[M]. 机械工业出版社, 2011.
- W.Richard Stevens, Stephen A.Rago, 史蒂文斯, 等. UNIX 环境高级编程 [M]. 人民邮电出版社, 2014.
- W. Richard Stevens, Bill Fenner, Andrew M. Rudoff. UNIX 网络编程 [M]. 人民邮电出版社, 2010.