-
- 2.1. 阻塞IO
- 2.2. 非阻塞IO
- 2.3. IO多路转接(IO多路复用)
- 2.4. 信号驱动IO
- 2.5. 异步IO
-
- [Reactor IO模型](#Reactor IO模型 "#ReactorIO")
-
- 4.1. 优缺点对比
1. 网络IO流程简介
对于一个 CS 架构的服务,其中少不了网络通信。而涉及到网络通信,自然就涉及到 Socket 编程,而网络IO模型
简单来说就是对Socket处理流程进行抽象。
注:本文中的IO仅代指网络IO
一个简化的Socket处理流程如下:
- 服务端创建Socket监听端口
- 服务端等待Socket连接建立
- 客户端建立Socket连接
- 服务端感知到Socket连接建立,继续执行代码,对Socket发起读请求
- 客户端写入数据
- 服务端感知到Socket数据准备就绪
- 服务端进行数据读取
- 服务端根据数据进行业务处理
2. 基础IO模型
在不断提升的网络并发要求下,服务端的IO模型也在不断演进,服务端的优化策略主要有这些方面:
- 多线程对IO请求进行处理,平衡CPU和IO的速度差异
- 通过避免内核空间频繁切换,降低线程数量等方式,降低服务端的性能损耗
- 批量处理多个IO请求
为了支持不同的IO模型,一般来说操作系统提供了多种IO相关的系统调用(也可以说是计算机硬件、操作系统、应用三层在实际的业务需求中相互影响而来)。
2.1. 阻塞IO
我们一般说阻塞IO即最基础的IO模型(BIO),所有步骤全部为单线程处理,例如第4步如果客户端没有发送数据,线程会阻塞住无法继续执行。
具体理解示例可以参考五种IO模型(详解+形象例子说明),写的很好不再赘述。
代码实现:
注:本文代码只在linux下验证,使用c/c++演示
下文代码实现了一个BIO服务端和一个客户端,服务端监听12345端口,客户端发送helloworld消息,服务端接收并将消息写回Socket。
服务端代码:
c
// bio_server.c
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
int main() {
int server_sock, client_sock;
char message[BUF_SIZE];
int str_len, i;
struct sockaddr_in server_addr, client_addr;
socklen_t client_addr_size;
server_sock = socket(PF_INET, SOCK_STREAM, 0);
if (server_sock == -1) {
perror("socket() error");
exit(1);
}
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(12345);
if (bind(server_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind() error");
exit(1);
}
// 步骤1
if (listen(server_sock, 5) == -1) {
perror("listen() error");
exit(1);
}
client_addr_size = sizeof(client_addr);
// 步骤2
client_sock = accept(server_sock, (struct sockaddr *)&client_addr, &client_addr_size);
if (client_sock == -1) {
perror("accept() error");
exit(1);
}
// 步骤4+ 6
while ((str_len = read(client_sock, message, BUF_SIZE)) != 0) {
message[str_len] = 0;
printf("Received message: %s\n", message);
write(client_sock, message, str_len);
close(client_sock);
close(server_sock);
return 0;
}
客户端代码:
c
// bio_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
int main() {
int sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in server_addr;
sock = socket(PF_INET, SOCK_STREAM, 0);
if (sock == -1) {
perror("socket() error");
exit(1);
}
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
server_addr.sin_port = htons(12345);
//步骤3
if (connect(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("connect() error");
exit(1);
}
strcpy(message, "helloworld");
//步骤5
write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE - 1);
message[str_len] = 0;
printf("Received message: %s\n", message);
close(sock);
return 0;
}
上述代码一个服务端每次只能处理一个线程,比较离谱,所以生产环境中一般不这么干,会单独起一个线程来处理客户端连接,就像下面这样:
c
// bio_pthread_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <pthread.h>
#define BUF_SIZE 1024
void *handle_socket(void *args);
int main()
{
int server_sock, client_sock;
int str_len, i;
struct sockaddr_in server_addr, client_addr;
socklen_t client_addr_size;
server_sock = socket(PF_INET, SOCK_STREAM, 0);
if (server_sock == -1)
{
perror("socket() error");
exit(1);
}
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(12345);
if (bind(server_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
{
perror("bind() error");
exit(1);
}
if (listen(server_sock, 5) == -1)
{
perror("listen() error");
exit(1);
}
//增加循环
while (1)
{
printf("waiting for client ...\n");
client_addr_size = sizeof(client_addr);
//步骤2
client_sock = accept(server_sock, (struct sockaddr *)&client_addr, &client_addr_size);
printf("new client connected ! \n");
if (client_sock == -1)
{
perror("accept() error");
exit(1);
}
pthread_t client_thread;
if (pthread_create(&client_thread, NULL, handle_socket, (void *)&client_sock) != 0)
{
perror("pthread create error");
close(client_sock);
continue;
}
//开辟新线程处理连接,主线程继续跑下一次循环
pthread_detach(client_thread);
printf("detach a thread to handle new client \n");
}
close(server_sock);
return 0;
}
//步骤4和步骤6放到这里
void *handle_socket(void *args)
{
int client_sock = *((int *)args);
char message[BUF_SIZE];
int str_len,recv_len;
while ((str_len = read(client_sock, message, BUF_SIZE)) != 0)
{
message[str_len] = 0;
printf("Received message: %s\n", message);
write(client_sock, message, str_len);
}
close(client_sock);
}
但是这样仍然有一些问题:
- 每次处理一个连接都起一个服务端线程,资源损耗较大
- 服务端每个线程只能监听一个socket,利用率较低
- 如果没有新的客户端连接,主线程会阻塞在accept函数上无法做其他事情
在其他模型中有一些解决方案。
2.2. 非阻塞IO
非阻塞IO即在阻塞IO的基础上,在第2步不会阻塞线程,而是有一个线程一直循环尝试读取数据,如果没有数据则继续循环,当数据准备好后,再读取数据,此方法对并发处理较好,但是对cpu占有率较高
代码实现:
注:后面代码就只贴关键部分,全部代码会放到github上
c
// bio_noblock_server.c
// ...省略服务端监听初始化代码...
//设置server socket为非阻塞模式
fcntl(server_sock, F_SETFL, O_NONBLOCK);
client_addr_size = sizeof(client_addr);
//建立连接时,由于accept函数不再阻塞,增加一个循环处理,没有获取到连接就继续循环
while(1){
printf("begin accept ...\n");
client_sock = accept(server_sock, (struct sockaddr *)&client_addr, &client_addr_size);
if (client_sock == -1) {
printf("no client connect! continue... \n");
sleep(2);
}else{
printf("accepted client connect\n");
break;
}
}
2.3. IO多路转接(IO多路复用)
IO多路转接的思路是基于阻塞IO的基础上,在步骤6和步骤7只需要用一个线程(即"复用")监听多个Socket的IO事件,可以大幅节省线程资源,只要有事件触发,它会唤起相应的处理线程来处理IO事件。
通常在linux环境下提供了select/poll/epoll系统调用来实现IO多路复用。
下面是基于select函数实现的IO多路复用:
c
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <sys/time.h>
#include <errno.h>
#define BUF_SIZE 1024
int main()
{
int server_sock = init_server_sock();
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(server_sock, &read_fds);
while (1)
{
select_loop(server_sock, read_fds);
}
close(server_sock);
return 0;
}
int select_loop(int server_sock, fd_set read_fds)
{
int max_fd = server_sock;
char buffer[BUF_SIZE];
struct sockaddr_in client_addr;
struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
socklen_t client_addr_size;
client_addr_size = sizeof(client_addr);
// 每次select事件触发后,结构会清空,需要调用select重新传入fds
int ret = select(server_sock + 1, &read_fds, NULL, NULL, &timeout);
if (ret == -1)
{
perror("select");
exit(1);
}
else if (ret == 0)
{
printf("Timeout");
return -1;
}
// server_sock如果触发了IO可读事件,说明有新连接建立
if (FD_ISSET(server_sock, &read_fds))
{
int client_sock = accept(server_sock, (struct sockaddr *)&client_addr, &client_addr_size);
if (client_sock == -1)
{
perror("accept");
return -1;
}
FD_SET(client_sock, &read_fds);
max_fd = client_sock > server_sock ? client_sock : server_sock;
}
int fd;
for (fd = server_sock+1; fd < max_fd + 1; fd++)
{
// client_sock如果成功建立,读数据
if (FD_ISSET(fd, &read_fds))
{
printf("read:%d\n", fd);
int recv_len = read(fd, buffer, BUF_SIZE - 1);
if (recv_len == -1)
{
perror("read");
close(fd);
FD_CLR(fd, &read_fds);
continue;
}
else if (recv_len == 0)
{
printf("Client disconnected");
close(fd);
FD_CLR(fd, &read_fds);
continue;
}
buffer[recv_len] = '\0';
printf("Received: %s\n", buffer);
send(fd, buffer, recv_len, 0);
}
}
}
int init_server_sock()
{
int server_sock, max_fd, min_fd;
struct sockaddr_in server_addr;
server_sock = socket(PF_INET, SOCK_STREAM, 0);
if (server_sock == -1)
{
perror("socket");
exit(1);
}
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(12345);
if (bind(server_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
{
perror("bind");
exit(1);
}
if (listen(server_sock, 5) == -1)
{
perror("listen");
exit(1);
}
return server_sock;
}
其他的两种函数可以参考bio_poll_server.c
,bio_epoll_server.c
。
那么三个函数有什么区别呢,参数啥意思?
简单来说,select函数有几个参数,代表着读、写、异常事件类型的socket文件描述符集合及超时设置(在Linux中,每个Socket对应一个文件描述符):
c
//select函数定义
//在centos7,位于/usr/include/sys/select.h头文件中
extern int select (int __nfds, fd_set *__restrict __readfds,
fd_set *__restrict __writefds,
fd_set *__restrict __exceptfds,
struct timeval *__restrict __timeout);
select使用的参数类型fd_set支持的文件描述符数量有限(1024),而epoll函数针对数量限制做了提升,支持超过1024个描述符数量。
c
//poll函数定义
//pollfd可以传入更多的描述符,其他的功能和select并无太大区别
extern int poll (struct pollfd *__fds, nfds_t __nfds, int __timeout);
从示例代码可以看出在基于select和poll函数中,需要循环调用select和poll函数,不断进行文件描述符的传入,传入后在内核中还需要遍历所有的文件描述符,这样会进行较多的内核和用户空间的上下文切换,造成较大的资源损耗。所以Linux推出了epoll函数.
epoll的思路是首先在事件注册时,只拷贝一次文件描述符进内核,而且为每个文件描述符注册一个回调函数,当IO事件发生时,内核会回调函数吧文件描述符放入一个就绪列表,这样就不用每次都遍历所有的文件描述符检查是否就绪,只检查就绪列表中的即可。
epoll代码核心实现参考bio_epoll_server.c
,此处不做更深入的源码分析
2.4. 信号驱动IO
信号驱动IO的思路是,用户进程向内核注册SIGIO信号触发的回调函数。IO事件发生后(比如内核缓冲区数据就位)后,通知用户程序,用户进程在signal回调函数中进行业务处理。
但是信号驱动IO在TCP中,导致SIGIO通知的条件过多,而且没有告诉用户进程发生了什么IO事件,在TCP套接字的开发过程中很少使用。
但是在UDP套接字上,通过SIGIO信号进行下面两个事件的类型判断即可:
- 数据报到达套接字
- 套接字上发生错误
因此,在SIGIO出现的时候,用户进程很容易进行判断:如果不是发生错误,就是有数据报到达了,信号驱动IO在TCP编程中使用较少,本文暂未实现代码。
2.5. 异步IO
异步IO相当于注册一个IO请求到内核,当Socket的IO读事件被触发时,内核会直接执行这个IO请求,也就是IO流程由内核接管了,内核将相应的数据读入指定的地址,然后回调用户线程通知数据准备完成。
异步IO的缺点是要求内核有相应实现,而且占用内核的资源较多。 代码实现见aio_server.c
3. Reactor IO模型
Reactor IO模型严格来说也是一种IO模型,但是它在底层的IO上并没有什么变化,而是基于多路复用IO模型的API和一些职责分离的编程模式进行组合而成的一种较高层次的IO模型,我一般视为是一个应用级别的IO模型(或者叫设计模式),常见的应用包括nginx、redis、netty底层都应用了这种IO模型。
Reactor从字面翻译为响应式,也就是有事件发生再进行响应的处理,他的思路是把事件的监听者和事件的处理者职责分离,线程分离,这样会提高一定的性能,也有更高的扩展性。
所以说具体实现中,基本的Reactor模型主要有事件监听器(Reactor)和事件处理器(Handler)两个角色。而部分生产实现中,会将Reactor分为主从两个角色,把建立客户端新连接的职责分离出来。
也即是一共三种角色:
- MainReactor(主反应器):负责建立客户端新连接,并分发相应的连接建立事件
- SubReactor(反应器):负责监听和分配事件,将IO事件分配到对应的handler
- Handler(处理器):处理Reactor分派的相应的事件 三种角色间一般都单独开启独立的线程,充分利用CPU,大大提升服务端处理大量链接的能力。
一个基础的单线程reactor编程模型实现:
c
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <sys/time.h>
#include <errno.h>
#define BUF_SIZE 1024
void handle_client_event(char buffer[BUF_SIZE], int recv_len, int fd);
int main()
{
int server_sock = init_server_sock();
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(server_sock, &read_fds);
while (1)
{
select_loop(server_sock, read_fds);
}
close(server_sock);
return 0;
}
int select_loop(int server_sock, fd_set read_fds)
{
int max_fd = server_sock;
struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
// 每次select事件触发后,结构会清空,需要调用select重新传入fds
int ret = select(server_sock + 1, &read_fds, NULL, NULL, &timeout);
if (ret == -1)
{
perror("select");
exit(1);
}
else if (ret == 0)
{
printf("Timeout");
return -1;
}
max_fd = react(server_sock, max_fd, read_fds);
}
//事件响应器
int react(int server_sock, int max_fd, fd_set read_fds)
{
struct sockaddr_in client_addr;
socklen_t client_addr_size;
client_addr_size = sizeof(client_addr);
char buffer[BUF_SIZE];
// server_sock如果触发了IO可读事件,说明有新连接建立
if (FD_ISSET(server_sock, &read_fds))
{
int client_sock = accept(server_sock, (struct sockaddr *)&client_addr, &client_addr_size);
if (client_sock == -1)
{
perror("accept");
return max_fd;
}
FD_SET(client_sock, &read_fds);
max_fd = client_sock > server_sock ? client_sock : server_sock;
}
int fd;
for (fd = server_sock + 1; fd < max_fd + 1; fd++)
{
// client_sock如果成功建立,读数据
if (FD_ISSET(fd, &read_fds))
{
printf("read:%d\n", fd);
int recv_len = read(fd, buffer, BUF_SIZE - 1);
if (recv_len == -1)
{
perror("read");
close(fd);
FD_CLR(fd, &read_fds);
continue;
}
else if (recv_len == 0)
{
printf("Client disconnected");
close(fd);
FD_CLR(fd, &read_fds);
continue;
}
handle_client_event(buffer, recv_len, fd);
}
}
return max_fd;
}
//业务处理器
void handle_client_event(char buffer[BUF_SIZE], int recv_len, int fd)
{
buffer[recv_len] = '\0';
printf("Received: %s\n", buffer);
send(fd, buffer, recv_len, 0);
}
int init_server_sock()
{
int server_sock, max_fd, min_fd;
struct sockaddr_in server_addr;
server_sock = socket(PF_INET, SOCK_STREAM, 0);
if (server_sock == -1)
{
perror("socket");
exit(1);
}
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(12345);
if (bind(server_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
{
perror("bind");
exit(1);
}
if (listen(server_sock, 5) == -1)
{
perror("listen");
exit(1);
}
return server_sock;
}
4. 总结
4.1. 优缺点对比
下面是各种IO模型的特点总结,没有一种模型在所有场景都是最好的(否则只会存在这一种模型了),而且各种io模型可能会有结合使用的情况,例如IO多路转接可以是阻塞的也可以是非阻塞的,使用中可以根据具体场景从去具体分析,找到最适合当前场景的IO模型实现
- 阻塞IO:
- 优点:实现简单易于理解,各操作系统兼容性较好。
- 缺点:在等待IO操作完成的过程中,进程无法做其他的事情,导致CPU资源的浪费。
- 非阻塞IO:
- 优点:发起调用后,若当前不具备IO条件,则立即返回,可以做其他的事情,提高了当前进程的效率。
- 缺点:不断从应用向内核发起轮询请求,CPU占用率较高。
- IO多路转接:
- 优点:可以实现多个IO操作的并发执行,提高了效率。
- 缺点:需要维护多个IO连接的状态和切换逻辑,实现较为复杂。
- 信号驱动IO:
- 优点:IO就绪时,通过信号通知进程进行IO操作,实现了异步处理,提高了CPU的利用率。
- 缺点:需要处理信号和IO操作的映射关系,而且信号的种类是有限的,实现较为复杂且扩展性较差。
- 异步IO:
- 优点:通过异步IO告诉操作系统哪些数据需要拷贝到何处,等待与拷贝的过程都由操作系统完成,进一步提高了CPU的利用率。
- 缺点:流程最为复杂,需要依赖操作系统的支持,内核占用率较高。
- Reactor IO模型:
- 优点:采用事件驱动的方式,避免了阻塞和轮询的开销,提高了资源利用率和效率,高效。
- 缺点:需要维护事件循环和回调函数的逻辑,实现相对复杂。