参考:
https://time.geekbang.org/column/article/143388
C10K问题
什么是C10K 问题
C10K 问题是这样的:如何在一台物理机上同时服务 10000 个用户?这里 C 表示并发,10K 等于 10000。得益于操作系统、编程语言的发展,在现在的条件下,普通用户使用框架或库就可以轻轻松松写出支持并发超过 10000 的服务器端程序,甚至于经过优化之后可以达到十万,乃至百万的并发,但在二十年前,突破 C10K 问题可费了不少的心思,是一个了不起的突破。
C10K的本质与考虑方面
C10K 问题本质上是一个操作系统问题,一台主机上同时支持 1 万个连接,需要考虑哪些方面?
文件句柄
每个客户连接都代表一个文件描述符,一旦文件描述符不够用了,新的连接就会被放弃并产生错误。在 Linux 下,单个进程打开的文件句柄数是有限制的,没有经过修改的值一般都是 1024。可以使用 root 权限修改 /etc/sysctl.conf 文件,使得系统可以支持 10000 个描述符上限。
系统内存
每个 TCP 连接占用的资源不简单的就是一个连接套接字,还需要占用一定的发送缓冲区和接收缓冲区。Linux 5.4.0 下发送缓冲区和接收缓冲区的值如下:
leacock@leacock-virtual-machine:~$ cat /proc/sys/net/ipv4/tcp_wmem
4096 16384 4194304
leacock@leacock-virtual-machine:~$ cat /proc/sys/net/ipv4/tcp_rmem
4096 131072 6291456
leacock@leacock-virtual-machine:~$
这三个值分别表示了最小分配值、默认分配值和最大分配值 。按照默认分配值计算,一万个连接需要的内存消耗为:
发送缓冲区: 16384*10000 = 160M bytes
接收缓冲区: 87380*10000 = 880M bytes
可见支持 1 万个并发连接,当下内存并不是一个巨大的瓶颈。
网络带宽
假设 1 万个连接,每个连接每秒传输大约 1KB 的数据,那么带宽需要 10000 x 1KB/s x8 = 80Mbps。在当下千兆万兆网卡之下也是小菜一碟。
在系统资源层面,C10K 问题是可以解决的。但是,能解决并不意味着可以很好地解决。在网络编程中,涉及到频繁的用户态 - 内核态数据拷贝,设计不够好的程序可能在低并发的情况下工作良好,一旦到了高并发情形,其性能可能呈现出指数级别的损失。
两个层面考虑
要想解决 C10K 问题,就需要从两个层面上来统筹考虑。
-
第一个层面,应用程序如何和操作系统配合,感知 I/O 事件发生,并调度处理在上万个套接字上的 I/O 操作? 可参见IO模式与IO多路复用
-
第二个层面,应用程序如何分配进程、线程资源来服务上万个连接?
解决方案:
两条思路方向
主要思路有两个:
-
一个是对于每个连接处理分配一个独立的进程/线程;
-
另一个思路是用同一进程/线程来同时处理若干连接。
几种解决方案
-
阻塞 I/O + 进程
-
阻塞 I/O + 线程
-
非阻塞 I/O + readiness notification + 单线程
-
非阻塞 I/O + readiness notification + 多线程
-
异步 I/O+ 多线程
阻塞 I/O + 进程
最为简单直接最传统的方式,每个连接通过 fork 派生一个子进程进行处理,由于一个独立的子进程负责处理了该连接所有的 I/O,所以即便是阻塞 I/O,多个连接之间也不会互相影响。方法虽然简单,但是效率不高,扩展性差,资源占用率高。要处理好父子进程、僵尸进程等。
父进程和子进程
创建一个新的进程,使用函数 fork 就可以
pid_t fork(void)
返回:在子进程中为0,在父进程中为子进程ID,若出错则为-1
在调用该函数的进程(即为父进程)中返回的是新派生的进程 ID 号,在子进程中返回的值为 0。通过返回值可以区分父子进程然后进行相应的处理。
当一个子进程退出时,系统内核还保留了该进程的若干信息,比如退出状态。这样的进程如果不回收,就会变成僵尸进程。由父进程派生出来的子进程,也必须由父进程负责回收,否则子进程就会变成僵尸进程。
有两种方式可以在子进程退出后回收资源,分别是调用 wait 和 waitpid 函数。
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
wait 和 waitpid 函数参见 https://blog.csdn.net/csdn_kou/article/details/81091191
函数 wait 和 waitpid 都可以返回两个值,一个是函数返回值,表示已终止子进程的进程 ID 号,另一个则是通过 statloc 指针返回子进程终止的实际状态。这个状态可能的值为正常终止、被信号杀死、作业控制停止等。
处理子进程退出的方式一般是注册一个信号处理函数,捕捉信号 **SIGCHILD **信号,然后再在信号处理函数里调用 waitpid 函数来完成子进程资源的回收。SIGCHLD 是子进程退出或者中断时由内核向父进程发出的信号,默认这个信号是忽略的。
阻塞 I/O + 进程 代码示例
GitHub:BIOAndProgressDemo
fork
服务端:
#define MAX_LINE 4096
#define SERV_PORT 5555
char convert_char(char c) {
if ( 'A' <= c && c <= 'Z')
return c + 32; // 转换小写
else if ( 'a' <= c && c <= 'z')
return c - 32; // 转换大写
else
return c; // 其他不变
}
void child_run(int fd) {
printf("child_run int fd = %d\n",fd);
char outbuf[MAX_LINE + 1];
size_t outbuf_used = 0;
ssize_t result;
char ch[128];
while (1) {
bzero(outbuf,MAX_LINE + 1);
bzero(ch,128);
result = recv(fd, &ch, 128, 0);
if (result == 0) {
// 这里表示对端的socket已正常关闭.
break;
} else if (result == -1) {
perror("read");
break;
}
u_long len = strlen(ch);
outbuf_used = 0;
for (int i = 0; i < len; ++i) {
outbuf[outbuf_used++] = convert_char(ch[i]);
}
send(fd, outbuf, outbuf_used, 0);
}
printf("child_run out\n");
}
/**
* 信号处理函数
* @param sig
*/
void sigchld_handler(int sig) {
/// pid = -1 等待任何一个子进程退出,没有任何限制,此时waitpid和wait的作用一模一样
/// WNOHANG 若由pid指定的子进程未发生状态改变(没有结束),则waitpid()不阻塞,立即返回0
while (waitpid(-1, 0, WNOHANG) > 0);
printf("sigchld_handler out\n");
}
/**
* 创建服务端套 并 返回 监听套接字
* @param port 监听端口
* @return 监听套接字
*/
int tcp_server_listen(int port) {
int listenfd;
/// 监听套接字
listenfd = socket(AF_INET, SOCK_STREAM, 0);
/// 填写 sockaddr_in
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(port);
int on = 1;
/// 设置属性
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
/// 绑定ip
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}
/// 监听 套接字
int rt2 = listen(listenfd, 1024);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}
/// 捕获SIGPIPE信号 参见 https://blog.csdn.net/xinguan1267/article/details/17357093
signal(SIGPIPE, SIG_IGN);
return listenfd;
}
int main(int c, char **v) {
/// 创建服务端
int listener_fd = tcp_server_listen(SERV_PORT);
/// 捕获 SIGCHLD 信号, 设置信号处理函数 sigchld_handler
signal(SIGCHLD, sigchld_handler);
/// 循环 监听 有连接到来 fork 进程处理
while (1) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
/// accept
int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
if (fd < 0) { /// accept 失败
error(1, errno, "accept failed");
exit(1);
}
if (fork() == 0) { /// fork 子进程 并通过返回值 区分 子父进程
/// 子进程
close(listener_fd); /// 关闭从父进程复制来的 listener_fd
child_run(fd); /// 运行子程序
exit(0);
} else {
/// 父进程
close(fd);
}
}
return 0;
}
客户端:
#define MAXLINE 4096
#define SERV_PORT 5555
int main() {
int sockfd;
struct sockaddr_in servaddr;
// 创建了一个本地套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror( "create socket failed");
}
// 初始化目标服务器端的地址, TCP 编程中,使用的是服务器的 IP 地址和端口作为目标
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
// 发起对目标套接字的 connect 调用
if (connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {
perror("connect failed");
}
char send_line[MAXLINE];
bzero(send_line, MAXLINE);
char recv_line[MAXLINE];
bzero(recv_line, MAXLINE);
// 从标准输入中读取字符串,向服务器端发送
while (1) {
bzero(recv_line,MAXLINE); // 注意每次清空
if (fgets(send_line, MAXLINE, stdin) == NULL)
break;
int nbytes = sizeof(send_line);
if (send(sockfd, send_line, nbytes,0) != nbytes)
perror("write error");
bzero(recv_line, MAXLINE); // 注意每次清空
if (recv(sockfd, recv_line, MAXLINE,0) == 0)
perror("server terminated prematurely");
fputs(recv_line, stdout);
}
exit(0);
}
测试:
阻塞 I/O + 线程
使用进程模型来处理用户连接请求,进程切换上下文的代价是比较高的,有一种轻量级的模型可以处理多用户连接请求,就是线程模型。
线程由操作系统内核管理。每个线程都有自己的上下文(context),包括一个可以唯一标识线程的 ID(thread ID,或者叫 tid)、栈、程序计数器、寄存器等。在同一个进程中,所有的线程共享该进程的整个虚拟地址空间,包括代码、数据、堆、共享库等。
主要线程函数
创建线程
int pthread_create(pthread_t *tid, const pthread_attr_t *attr,
void *(*func)(void *), void *arg);
返回:若成功则为0,若出错则为正的Exxx值
-
第一个参数为指向线程标识符的指针。创建线程成功,tid 就返回正确的线程 ID
-
第二个参数用来设置线程属性。如优先级、是否为守护进程等,如无特殊设置,可以直接指定这个参数为 NUL。
-
第三个参数是线程运行函数的起始地址。
-
最后一个参数是运行函数的参数。如果我们想给线程入口函数传多个值,那么需要把这些值包装成一个结构体
在新线程的入口函数内,可以执行 pthread_self 函数返回线程 tid。
pthread_t pthread_self(void)
终止线程
终止一个线程最直接的方法是在父线程内调用函数:
void pthread_exit(void *status)
调用这个函数之后,父线程会等待其他所有的子线程终止,之后父线程自己终止。
但是绝大多数的子线程执行体都是一个无限循环。也可以通过调用 pthread_cancel 来主动终止一个子线程,和 pthread_exit 不同的是,它可以指定某个子线程终止。
int pthread_cancel(pthread_t tid)
回收已终止线程的资源
pthread_join 回收已终止线程的资源
int pthread_join(pthread_t tid, void ** thread_return)
当调用 pthread_join 时,主线程会阻塞,直到对应 tid 的子线程自然终止。和 pthread_cancel 不同的是,它不会强迫子线程终止。
分离线程
一个线程的重要属性是可结合的,或者是分离的。一个可结合的线程是能够被其他线程杀死和回收资源的;而一个分离的线程不能被其他线程杀死或回收资源。一般来说,默认的属性是可结合的。
pthread_detach 函数可以分离一个线程:
int pthread_detach(pthread_t tid)
阻塞 I/O + 线程 代码示例
GitHub:BIOAndThreadDemo
对上面服务端稍作修改,客户端不变
pthread
服务端:
#include <sys/socket.h>
#include <netinet/in.h>
#include <strings.h>
#include <error.h>
#include <errno.h>
#include <signal.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <pthread.h>
#define MAX_LINE 4096
#define SERV_PORT 5555
char convert_char(char c) {
if ( 'A' <= c && c <= 'Z')
return c + 32; // 转换小写
else if ( 'a' <= c && c <= 'z')
return c - 32; // 转换大写
else
return c; // 其他不变
}
void thread_run(void *arg) {
pthread_detach(pthread_self());
int fd = (int)arg;
printf("thread_run int fd = %d\n",fd);
char outbuf[MAX_LINE + 1];
size_t outbuf_used = 0;
ssize_t result;
char ch[128];
while (1) {
bzero(outbuf,MAX_LINE + 1);
bzero(ch,128);
result = recv(fd, &ch, 128, 0);
if (result == 0) {
// 这里表示对端的socket已正常关闭.
break;
} else if (result == -1) {
perror("read");
break;
}
u_long len = strlen(ch);
outbuf_used = 0;
for (int i = 0; i < len; ++i) {
outbuf[outbuf_used++] = convert_char(ch[i]);
}
send(fd, outbuf, outbuf_used, 0);
}
printf("thread_run out\n");
}
/**
* 创建服务端套 并 返回 监听套接字
* @param port 监听端口
* @return 监听套接字
*/
int tcp_server_listen(int port) {
int listenfd;
/// 监听套接字
listenfd = socket(AF_INET, SOCK_STREAM, 0);
/// 填写 sockaddr_in
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(port);
int on = 1;
/// 设置属性
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
/// 绑定ip
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}
/// 监听 套接字
int rt2 = listen(listenfd, 1024);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}
/// 捕获SIGPIPE信号 参见 https://blog.csdn.net/xinguan1267/article/details/17357093
signal(SIGPIPE, SIG_IGN);
return listenfd;
}
int main(int c, char **v) {
/// 创建服务端
int listener_fd = tcp_server_listen(SERV_PORT);
pthread_t tid;
/// 循环 监听 有连接到来 fork 进程处理
while (1) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
/// accept
int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
if (fd < 0) { /// accept 失败
error(1, errno, "accept failed");
} else {
pthread_create(&tid, NULL, &thread_run, (void *) fd);
}
}
return 0;
}
测试:
非阻塞 I/O + readiness notification + 单线程(reactor)
事件驱动模型
事件驱动模型,也被叫做反应堆模型(reactor),或者是 Event loop 模型。这个模型的核心有两点。
- 第一,它存在一个无限循环的事件分发线程,或者叫做 reactor 线程、Event loop 线程。这个事件分发线程的背后,就是 poll、epoll 等 I/O 分发技术的使用。
- 第二,所有的 I/O 操作都可以抽象成事件,每个事件必须有回调函数来处理。通过事件分发,这些事件都可以一一被检测,并调用对应的回调函数加以处理。
single reactor thread
一个 reactor 线程上同时负责分发 acceptor 的事件、已连接套接字的 I/O 事件。
single reactor thread + worker threads
进一步优化将耗时的操作分离出来,反应堆线程只负责处理 I/O 相关的工作,业务逻辑相关的工作都被裁剪成一个一个的小任务,放到线程池里由空闲的线程来执行。当结果完成后,再交给反应堆线程,由反应堆线程通过套接字将结果发送出去。
非阻塞 I/O + readiness notification + 单线程(reactor)代码示例
参见:
非阻塞 I/O + readiness notification + 多线程(主 - 从 reactor)
主 - 从 reactor 模式
单 reactor 线程既分发连接建立,又分发已建立连接的 I/O;将 acceptor 上的连接建立事件和已建立连接的 I/O 事件分离,形成所谓的主 - 从 reactor 模式。
主 - 从这个模式的核心思想是,主反应堆线程只负责分发 Acceptor 连接建立,已连接套接字上的 I/O 事件交给 sub-reactor 负责分发。其中 sub-reactor 的数量,可以根据 CPU 的核数来灵活设置。
主反应堆线程一直在感知连接建立的事件,如果有连接成功建立,主反应堆线程通过 accept 方法获取已连接套接字,接下来会按照一定的算法选取一个从反应堆线程,并把已连接套接字加入到选择好的从反应堆线程中。主反应堆线程唯一的工作,就是调用 accept 获取已连接套接字,以及将已连接套接字加入到从反应堆线程中。
主 - 从 reactor+worker threads 模式
主 - 从 reactor 模式解决了 I/O 分发的高效率问题,那么 work threads 就解决了业务逻辑和 I/O 分发之间的耦合问题。
主 - 从反应堆下加上 worker 线程池。
主 - 从反应堆跟上面介绍的做法是一样的。和上面不一样的是,这里将 decode、compute、encode 等 CPU 密集型的工作从 I/O 线程中拿走,这些工作交给 worker 线程池来处理,而且这些工作拆分成了一个个子任务进行。encode 之后完成的结果再由 sub-reactor 的 I/O 线程发送出去。
非阻塞 I/O + readiness notification + 多线程(主 - 从 reactor) 代码示例
未实现
异步 I/O+ 多线程
待整理
可以看下 AIO 的新归宿:io_uring