前言
本篇文章会先介绍一些基础的网络开发知识,以及常见的IO模型,然后注重介绍IO多路复用模型的使用方式和linux下的接口函数。
IO模型
在理解IO多路复用之前先介绍一下目前常用的几种IO模型
- 同步阻塞模型
- 同步非阻塞模型
- IO多路复用模型
- 异步模型
上述的划分方式并不严谨,因为IO多路复用不是从同步/异步和阻塞/非阻塞的角度出发。严格来说,IO多路复用是属于同步阻塞IO的,但相对于普通的同步阻塞模型,又有特殊的地方,我们后面会讲。
这里同步/异步和阻塞/非阻塞的概念很容易混淆,尤其是同步/异步,不同上下文中的同步/异步的定义可能不一样,为了方便,我们在这里规定一下:
同步/异步指消息传递的方式,同步是指我们主动的查询IO状态,异步则是被动的由内核通知。
阻塞/非阻塞指线程(进程)的状态,阻塞的操作会使线程陷入等待态,等待操作的完成,非阻塞z则相反,不会使进程陷入等待态。
在阐述具体的模型之前我们先梳理一下一个服务器建立的流程:
- 创建Socket,绑定到某个端口
- 调用系统调用开始监听端口
- 调用accept方法,等待客户端的连接
- 建立连接后传输数据
- 网络数据包到达网卡
- 通过DMA方式,将网卡中的数据拷贝到内核缓冲区
- 将内核缓冲区的数据拷贝到用户空间的缓冲区
- 用户处理数据
由于外中断都是异步的,无法确定连接和数据包什么时候到来,所以我们必须采用某种方式来等待数据包。而正是不同的IO模型决定了我们用何种方式/状态来等待数据包,实际上这点很像CPU采用不同的方式(轮询/中断/DMA)处理IO一样。
同步阻塞模型
同步阻塞是最常见的模型,我们调用系统调用时会使当前进程进入阻塞状态,直到连接或者数据包到来再唤醒进程继续执行。该模型最大的缺点是,一个线程同一时间只能处理一个socket,其间也不能干其它事。
为了服务器同时能够相应多个客户端的连接,我们必须使用多线程,为每个客户端创建一个线程。
//创建socket
int socket_fd = socket(AF_INET,SOCK_STREAM,0);
//地址描述结构体
sockaddr_in addr;
//ipv4协议栈
addr.sin_family = AF_INET;
//转换为大端
addr.sin_port = htons(port);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(socket_fd,(sockaddr*)&addr,sizeof(addr));
listen(socket_fd,MAX_QUEUE);
cout<<"等待客户端连接"<<endl;
while (1)
{
sockaddr_in in_addr;
socklen_t addr_len = sizeof(in_addr);
//该方法会一直阻塞直到有客户端连接
int client_fd = accept(socket_fd,(sockaddr*)&in_addr,&addr_len);
cout<<"客户端已连接,ip:"<<in_addr.sin_addr.s_addr<<";端口号:"<<in_addr.sin_port<<endl;
//开启线程处理数据
new std::thread([client_fd](){
char buffer[1024];
//该方法会阻塞,直到有数据可读
int len = read(client_fd,buffer,1024);
if(len != -1){
cout<<string(buffer)<<endl;
}
});
}
缺点: 需要为每一个进程创建一个线程,对于大量的短链接(传输数据后立马释放),使用线程池后其实还好。但如果都是长连接且传输数据又没有很频繁时就会出现问题,许多连接很久没有数据传输却使线程一直处于阻塞状态,导致线程数量急速增加。
同步非阻塞模型
同步阻塞模型的主要问题是accept、read等api会使线程一直处于阻塞状态,当数据传输不频繁时会导致线程浪费,而非阻塞模型可以解决这一个问题。
如果没有数据或者连接到来,函数会立即返回并告诉你状态而不是一直阻塞。但这会产生另外一个问题,由于数据到达的时间不可预测,我们要不停的轮询查看socket的状态。
以accept方法为例,如果无连接则返回-1,并且将errno设置为11(EWOLUDBLOCK/EAGAIN)。如果有连接接入则返回socket描述符,那我们的代码可能会变成下面这样。
while(1){
int client_fd = -1;
if((client_fd = accept()) == -1){
if(errno == EWOLUDBLOCK){
continue;
}
//其它错误
}
//处理数据
std::thread([client_fd](){
//处理
});
}
初步看上去,这个方式并没有什么优势,因为我们还要使用轮询来不断的检测socket状态,甚至还不如阻塞,至少阻塞不会消耗CPU资源。
但换个方式考虑,由于所有的方法都不会阻塞,我们可以通过轮询,用一个线程监听多个socket的状态,每次将需要读写的socket过滤出来放到线程池中执行,执行完之后再放入到检查队列,就可以避免没有数据的socket仍然阻塞线程。
struct SocketItem {
int socket_fd;
int event;
function<void(SyncQueue<SocketItem *> &socket_queue, SocketItem *item)> callback =
[](SyncQueue<SocketItem *> &socket_queue, SocketItem *item) {};
};
const uint32_t ACCEEPT = 1 << 0;
const uint32_t READ = 1 << 1;
void runSyncUnBlockServer(int port) {
using namespace std;
//线程安全队列,同时储存多个socket_fd
SyncQueue<SocketItem *> socket_queue;
//创建socket并配置为unblock
int socket_fd = create_socket_and_listen(port);
auto *acceptItem = new SocketItem();
//监听accept事件
acceptItem->event = ACCEEPT;
acceptItem->socket_fd = socket_fd;
acceptItem->callback = handleAccept;
socket_queue.push(acceptItem);
while (1) {
if (socket_queue.empty()) {
continue;
}
auto *item = socket_queue.pop();
bool resolve = false;
switch (item->event) {
case ACCEEPT: {
sockaddr_in in_addr;
socklen_t addr_len = sizeof(in_addr);
int client_fd =
accept(item->socket_fd, (sockaddr *)&in_addr, &addr_len);
if (client_fd != -1) { //服务器连接
resolve = true;
new thread([&socket_queue,item](){
item->callback(socket_queue,item);
});
}
break;
}
case 2: {
int count = 0;
ioctl(item->socket_fd, FIONREAD, &count);
//可读
if (count > 0) {
resolve = true;
new thread([&socket_queue,item]() {
item->callback(socket_queue,item);
});
}
break;
}
}
if (!resolve) {
socket_queue.push(item);
}
}
}
IO多路复用
我们使用同步非阻塞模型时用select方式一直轮询的检查多个文件描述符,虽然能够减少现成的创建,但是不断的轮询仍然要占用CPU资源。试想一下,假如我们调用select()方法时,如果没有我们感兴趣的事件则阻塞,如果有某一个或多个socket符合我们的事件,然后再唤醒进程,这样就可以减少轮询带来的CPU资源消耗。
要实现这样的方式需要内核的协助,好在linux提供了对应的函数:select()、poll()、epoll()。三个的功能都是类似的,注册一个socket集合和事件,调用时阻塞,当其中任何一个或多个有事件相应时返回一个socket集合。区别就在于查询时的性能不一样,我们在这里只说epoll。
int lisen_socket_fd = create_socket_and_listen(port);
cout<<"listene..."<<endl;
int epoll_fd = epoll_create1(0);
epoll_event event, events[MAX_QUEUE];
event.events = EPOLLIN;
event.data.fd = lisen_socket_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, lisen_socket_fd, &event);
while (1) {
int count = epoll_wait(epoll_fd, events, MAX_QUEUE, -1);
for (int i = 0; i < count; i++) {
sockaddr_in addr;
socklen_t addr_len = 0;
int client_fd =
accept(events[i].data.fd, (sockaddr *)&addr, &addr_len);
cout << "接入port:" << addr.sin_port<<"\t"<<client_fd << endl;
}
}
IO多路复用模型可以使用一个进程监听多个socket的状态,当socket有状态就绪时在线程池中调用相应的回调,这样就构成我们通常说的reactor模型。
异步IO模型
异步模型相比IO多路复用更进一步。多路复用中有事件到来时内核会通知我们,然后我们自己处理数据,调用read/write方法将数据从内核空间读入到用户空间(写入时相反)。
异步模型当我们调用read/write时内核直接帮助我们完成相应的任务,直接将数据从内核读到用户空间(写入时相反),然后调用回调,直接处理数据。
由于linux对异步IO的支持并不完善,目前用的并不多,而且性能相比多路复用并没有什么优势。相对之下,windows平台异步IO支持很好,能够显著的提升性能。
附录
必要的头文件
#include <sys/types.h> //系统类型库
#include <sys/socket.h> //socket库,用于创建socket
#include <sys/epoll.h> //epoll函数库
#include <sys/ioctl.h> //icotl函数库,用来检测可读数据大小
#include <netinet/in.h> //包含表示socket地址的socket_addr的结构体定义
#include <thread.h> //多线程库
#include <unistd.h> //包含POSIX通用函数,如:close
AddressFamily
由于Socket不仅仅用来创建跨主机的TCP/UCP网络,还可以用来进行本机的进程通信,蓝牙通信等,所以指定socket_addr时需要指定地址的类型
Name Purpose
AF_UNIX, AF_LOCAL Local communication
AF_INET IPv4 Internet protocols
AF_INET6 IPv6 Internet protocols
AF_IPX IPX - Novell protocols
AF_NETLINK Kernel user interface device
AF_X25 ITU-T X.25 / ISO-8208 protocol
AF_AX25 Amateur radio AX.25 protocol
AF_ATMPVC Access to raw ATM PVCs
AF_APPLETALK Appletalk
AF_PACKET Low level packet interface