阻塞I/O的进程模型
fork 函数:
pid_t fork(void)
返回:在子进程中为0,在父进程中为子进程ID,若出错则为-1
程序调用 fork 一次,却在父、子进程里各返回一次。在调用该函数的进程(即为父进程)中返回的是新派生的进程 ID 号,在子进程中返回的值为 0。fork 函数实现的时候,实际上会把当前父进程的所有相关值都克隆一份,包括地址空间、打开的文件描述符、程序计数器等,就连执行代码也会拷贝一份,新派生的进程的表现行为和父进程近乎一样,就好像是派生进程调用过 fork 函数一样。
if(fork() == 0){
do_child_process(); //子进程执行代码
}else{
do_parent_process(); //父进程执行代码
}
当一个子进程退出时,系统内核还保留了该进程的若干信息,比如退出状态。这样的进程如果不回收,就会变成僵尸进程。在 Linux 下,这样的“僵尸”进程会被挂到进程号为 1 的 init 进程上。所以,由父进程派生出来的子进程,也必须由父进程负责回收,否则子进程就会变成僵尸进程。僵尸进程会占用不必要的内存空间,如果量多到了一定数量级,就会耗尽我们的系统资源。
有两种方式可以在子进程退出后回收资源,分别是调用 wait 和 waitpid 函数。
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
函数 wait 和 waitpid 都可以返回两个值,一个是函数返回值,表示已终止子进程的进程 ID 号,另一个则是通过 statloc 指针返回子进程终止的实际状态。这个状态可能的值为正常终止、被信号杀死、作业控制停止等。如果没有已终止的子进程,而是有一个或多个子进程在正常运行,那么 wait 将阻塞,直到第一个子进程终止。
waitpid 可以认为是 wait 函数的升级版,它的参数更多,提供的控制权也更多。pid 参数允许我们指定任意想等待终止的进程 ID,值 -1 表示等待第一个终止的子进程。options 参数给了我们更多的控制选项。
处理子进程退出的方式一般是注册一个信号处理函数,捕捉信号 SIGCHILD 信号,然后再在信号处理函数里调用 waitpid 函数来完成子进程资源的回收。SIGCHLD 是子进程退出或者中断时由内核向父进程发出的信号,默认这个信号是忽略的。所以,如果想在子进程退出时能回收它,需要像下面一样,注册一个 SIGCHILD 函数。
signal(SIGCHLD, sigchld_handler);
一张图展示这种进程模型:
服务端程序举个例子:
#define MAX_LINE 4096
char rot13_char(char c) {
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
return c + 13;
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
return c - 13;
else
return c;
}
void child_run(int fd) {
char outbuf[MAX_LINE + 1];
size_t outbuf_used = 0;
size_t result;
while (1) {
char ch;
result = recv(fd, &ch, 1, 0);
if (result == 0) {
break;
} else if (result == -1) {
printf("read");
break;
}
if (outbuf_used < sizeof(outbuf)) {
outbuf[outbuf_used++] = rot13_char(ch);
}
if (ch == '\n') {
send(fd, outbuf, outbuf_used, 0);
outbuf_used = 0;
continue;
}
}
}
void sigchld_handler(int sig) {
/*WNOHANG 用来告诉内核,即使还有未终止的子进程也不要阻塞在 waitpid 上
因为 wait 函数在有未终止子进程的情况下,没有办法不阻塞*/
/*一个waitpid不足够阻止僵尸进程,如果n个子进程同时停止,那么会同时发出n个SIGCHILD信号给父进程,但是信号处理函数执行一次,因为信号一般是不排队的,多个SIGCHILD只会发送一次给父进程。所以需要用循环waitpid处理,获取所有终止子进程状态。*/
while (waitpid(-1, 0, WNOHANG) > 0);
return;
}
int main()
{
struct servaddr_in serv_addr;
int listener_fd = socket(PF_INET, SOCK_STREAM, 0);
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(7878);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
bind(listenfd, (struct sockaddr*) &serv_addr, sizeof(serv_addr));
listen(listenfd, SOMAXCONN);
signal(SIGCHLD, sigchld_handler);
while (1) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
if (fd < 0) {
printf("accept failed\n");
exit(0);
}
if (fork() == 0) {
//子进程不需要关心监听套接字
close(listener_fd);
child_run(fd);
exit(0);
} else {
//父进程不需要关心连接套接字
close(fd);
}
}
return 0;
}
说说这里的close函数,从父进程派生出的子进程,同时也会复制一份描述字,就是说,连接套接字和监听套接字的引用计数都会被加 1,而调用 close 函数则会对引用计数进行减 1 操作,这样在套接字引用计数到 0 时,才可以将套接字资源回收。所以,这里的 close 函数非常重要,缺少了它们,就会引起服务器端资源的泄露。
阻塞I/O的线程模型
在同一个进程下,线程上下文切换的开销要比进程小得多。怎么理解线程上下文呢?我们的代码被 CPU 执行的时候,是需要一些数据支撑的,比如程序计数器告诉 CPU 代码执行到哪里了,寄存器里存了当前计算的一些中间值,内存里放置了一些当前用到的变量等,从一个计算场景,切换到另外一个计算场景,程序计数器、寄存器等这些值重新载入新场景的值,就是线程的上下文切换。
POSIX 线程是现代 UNIX 系统提供的处理线程的标准接口。POSIX 定义的线程函数大约有 60 多个,这些函数可以帮助我们创建线程、回收线程。
主要线程函数
- 线程创建
int pthread_create(pthread_t *tid, const pthread_attr_t *attr,
void *(*func)(void *), void *arg);
返回:若成功则为0,若出错则为正的Exxx值
在新线程的入口函数内,可以执行 pthread_self 函数返回线程 tid。
- 线程终止
void pthread_exit(void *status)
当调用这个函数之后,父线程会等待其他所有的子线程终止,之后父线程自己终止。当然,如果一个子线程入口函数直接退出了,那么子线程也就自然终止了。所以,绝大多数的子线程执行体都是一个无限循环。
也可以通过调用 pthread_cancel 来主动终止一个子线程,和 pthread_exit 不同的是,它可以指定某个子线程终止。
int pthread_cancel(pthread_t tid)
- 回收已终止线程的资源
int pthread_join(pthread_t tid, void ** thread_return)
当调用 pthread_join 时,主线程会阻塞,直到对应 tid 的子线程自然终止。和 pthread_cancel 不同的是,它不会强迫子线程终止。
- 线程分离
一个线程的重要属性是可结合的,或者是分离的。一个可结合的线程是能够被其他线程杀死和回收资源的;而一个分离的线程不能被其他线程杀死或回收资源。一般来说,默认的属性是可结合的。
int pthread_detach(pthread_t tid)
在高并发的例子里,每个连接都由一个线程单独处理,在这种情况下,服务器程序并不需要对每个子线程进行终止,这样的话,每个子线程可以在入口函数开始的地方,把自己设置为分离的,这样就能在它终止后自动回收相关的线程资源了,就不需要调用 pthread_join 函数了。
每个连接一个线程处理
服务端程序举例:
extern void loop_echo(int);
void thread_run() {
pthread_detach(pthread_self());
int fd = (int) arg;
loop_echo(fd);
}
int main(int c, char **v) {
struct servaddr_in serv_addr;
int listener_fd = socket(PF_INET, SOCK_STREAM, 0);
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(7878);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
bind(listenfd, (struct sockaddr*) &serv_addr, sizeof(serv_addr));
listen(listenfd, SOMAXCONN);
pthread_t tid;
while (1) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
if (fd < 0) {
printf("accept failed\n");
} else {
//通过强制把描述字转换为 void * 指针的方式完成传值,但是这个指针里存放的并不是一个地址,而是连接描述符的数值。
pthread_create(&tid, NULL, &thread_run, (void *) fd);
}
}
return 0;
}
loop_echo 的程序如下,在接收客户端的数据之后,再编码回送出去。
char rot13_char(char c) {
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
return c + 13;
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
return c - 13;
else
return c;
}
void loop_echo(int fd) {
char outbuf[MAX_LINE + 1];
size_t outbuf_used = 0;
ssize_t result;
while (1) {
char ch;
result = recv(fd, &ch, 1, 0);
//断开连接或者出错
if (result == 0) {
break;
} else if (result == -1) {
error(1, errno, "read error");
break;
}
if (outbuf_used < sizeof(outbuf)) {
outbuf[outbuf_used++] = rot13_char(ch);
}
if (ch == '\n') {
send(fd, outbuf, outbuf_used, 0);
outbuf_used = 0;
continue;
}
}
}
构建线程池处理多个连接
上面的服务器端程序虽然可以正常工作,不过它有一个缺点,那就是如果并发连接过多,就会引起线程的频繁创建和销毁。虽然线程切换的上下文开销不大,但是线程创建和销毁的开销却是不小的。我们可以使用预创建线程池的方式来进行优化。在服务器端启动时,可以先按照固定大小预创建出多个线程,当有新连接建立时,往连接字队列里放置这个新连接描述字,线程池里的线程负责从连接字队列里取出连接描述字进行处理。
这个程序的关键是连接字队列的设计,因为这里既有往这个队列里放置描述符的操作,也有从这个队列里取出描述符的操作。需要引入两个重要的概念,一个是锁 mutex,一个是条件变量 condition。锁很好理解,加锁的意思就是其他线程不能进入;条件变量则是在多个线程需要交互的情况下,用来线程间同步的原语。
//定义一个队列
typedef struct {
int number; //队列里的描述字最大个数
int *fd; //这是一个数组指针,队列本体
int front; //当前队列的头位置
int rear; //当前队列的尾位置
pthread_mutex_t mutex; //锁
pthread_cond_t cond; //条件变量
} block_queue;
//初始化队列
void block_queue_init(block_queue *blockQueue, int number) {
blockQueue->number = number;
blockQueue->fd = calloc(number, sizeof(int));
blockQueue->front = blockQueue->rear = 0;
pthread_mutex_init(&blockQueue->mutex, NULL);
pthread_cond_init(&blockQueue->cond, NULL);
}
//往队列里放置一个描述字fd
void block_queue_push(block_queue *blockQueue, int fd) {
//一定要先加锁,因为有多个线程需要读写队列
pthread_mutex_lock(&blockQueue->mutex);
//将描述字放到队列尾的位置
blockQueue->fd[blockQueue->rear] = fd;
//如果已经到最后,重置尾的位置
if (++blockQueue->rear == blockQueue->number) {
blockQueue->rear = 0;
}
printf("push fd %d", fd);
//通知其他等待读的线程,有新的连接字等待处理
pthread_cond_signal(&blockQueue->cond);
//解锁
pthread_mutex_unlock(&blockQueue->mutex);
}
//从队列里读出描述字进行处理
int block_queue_pop(block_queue *blockQueue) {
//加锁
pthread_mutex_lock(&blockQueue->mutex);
//判断队列里没有新的连接字可以处理,就一直条件等待,直到有新的连接字入队列
//这是为了确保被pthread_cond_wait唤醒之后的线程,确实可以满足继续往下执行的条件。如果没有while循环的再次确认,可能直接就往下执行了。
while (blockQueue->front == blockQueue->rear)
pthread_cond_wait(&blockQueue->cond, &blockQueue->mutex);
//取出队列头的连接字
int fd = blockQueue->fd[blockQueue->front];
//如果已经到最后,重置头的位置
if (++blockQueue->front == blockQueue->number) {
blockQueue->front = 0;
}
printf("pop fd %d", fd);
//解锁
pthread_mutex_unlock(&blockQueue->mutex);
//返回连接字
return fd;
}
服务端程序如下:
typedef struct {
pthread_t thread_tid; /* thread ID */
long thread_count; /* # connections handled */
} Thread;
void thread_run(void *arg) {
pthread_t tid = pthread_self();
pthread_detach(tid);
block_queue *blockQueue = (block_queue *) arg;
while (1) {
int fd = block_queue_pop(blockQueue);
printf("get fd in thread, fd==%d, tid == %d", fd, tid);
loop_echo(fd);
}
}
int main(int c, char **v) {
struct servaddr_in serv_addr;
int listener_fd = socket(PF_INET, SOCK_STREAM, 0);
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(7878);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
int on = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
bind(listen_fd, (struct sockaddr*) &serv_addr, sizeof(serv_addr));
listen(listen_fd, SOMAXCONN);
block_queue blockQueue;
block_queue_init(&blockQueue, BLOCK_QUEUE_SIZE);
thread_array = calloc(THREAD_NUMBER, sizeof(Thread));
int i;
for (i = 0; i < THREAD_NUMBER; i++) {
pthread_create(&(thread_array[i].thread_tid), NULL, &thread_run, (void *) &blockQueue);
}
while (1) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
if (fd < 0) {
printf("accept failed\n");
continue;
} else {
block_queue_push(&blockQueue, fd);
}
}
return 0;
}
连接字队列的实现里,有一个重要情况没有考虑,就是队列满了。不过和前面的程序相比,线程创建和销毁的开销大大降低,但因为线程池大小固定,又因为使用了阻塞套接字,肯定会出现有连接得不到及时服务的场景。这个问题的解决还是要回利用多路 I/O 复用加上线程来处理,仅仅使用阻塞 I/O 模型和线程是没有办法达到极致的高并发处理能力。
温故而知新 !