I/O多路复用(I/O Multiplexing)是一种通过一种机制同时监听多个文件描述符(sockets、文件、设备等)的技术。它可以使一个进程在等待多个 I/O 操作完成时不会阻塞,从而提高程序的性能和响应性。
通过这种方式在单线程/进程的场景下也可以在服务器端实现并发。常见的IO多路转接方式有:select、poll、epoll
。
I/O多路复用的优势在于它可以有效地管理大量的连接,避免了创建大量线程或进程的开销。它适用于需要同时处理多个连接,但每个连接的数据流量相对较小的情况,如网络服务器、聊天程序等。通过选择适当的多路复用机制,可以使程序更加高效地处理并发的 I/O 操作。
IO多路复用和多线程/进程的对比:
IO多路复用
与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
这个函数是跨平台的,Linux、Mac、Windows都支持
函数的函数原型:
#include <sys/select.h>
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval * timeout);
函数参数:
函数返回值:
另外初始化fd_set类型的参数还需要使用相关的一些列操作函数,具体如下:
// 将文件描述符fd从set集合中删除 == 将fd对应的标志位设置为0
void FD_CLR(int fd, fd_set *set);
// 判断文件描述符fd是否在set集合中 == 读一下fd对应的标志位到底是0还是1
int FD_ISSET(int fd, fd_set *set);
// 将文件描述符fd添加到set集合中 == 将fd对应的标志位设置为1
void FD_SET(int fd, fd_set *set);
// 将set集合中, 所有文件文件描述符对应的标志位设置为0, 集合中没有添加任何文件描述符
void FD_ZERO(fd_set *set);
这个类型的数据有128个字节,也就是1024个标志位,和内核中文件描述符表中的文件描述符个数是一样的。
下图中的fd_set中存储了要委托内核检测读缓冲区的文件描述符集合。
内核在遍历这个读集合的过程中,如果被检测的文件描述符对应的读缓冲区中没有数据,内核将修改这个文件描述符在读集合fd_set中对应的标志位,改为0,如果有数据那么这个标志位的值不变,还是1。
当select()函数解除阻塞之后,被内核修改过的读集合通过参数传出,此时集合中只要标志位的值为1,那么它对应的文件描述符肯定是就绪的,我们就可以基于这个文件描述符和客户端建立新连接或者通信了。
创建监听的套接字 lfd = socket();
将监听的套接字和本地的IP和端口绑定 bind()
给监听的套接字设置监听 listen()
创建一个文件描述符集合 fd_set
,用于存储需要检测读事件的所有的文件描述符
通过 FD_ZERO() 初始化
通过 FD_SET() 将监听的文件描述符放入检测的读集合中
循环调用select(),周期性的对所有的文件描述符进行检测
select() 解除阻塞返回,得到内核传出的满足条件的就绪的文件描述符集合
通过FD_ISSET() 判断集合中的标志位是否为 1
如果这个文件描述符是监听的文件描述符,调用 accept()
和客户端建立连接
? 将得到的新的通信的文件描述符,通过FD_SET() 放入到检测集合中
如果这个文件描述符是通信的文件描述符,调用通信函数和客户端通信(这个一定是在第二轮开始今后才能检测到)
? 如果客户端和服务器断开了连接,使用FD_CLR()将这个文件描述符从检测集合中删除
? 如果没有断开连接,正常通信即可
重复第6步
服务器端代码
//
// Created by 47468 on 2024/1/24.
// server.cpp
#include "arpa/inet.h"
#include <cstdio>
#include <cstring>
#include "unistd.h"
#include "iostream"
#include "string"
#include "cctype"
using namespace std;
int main(){
// 1. 创建监听的套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 绑定
sockaddr_in saddr{};
saddr.sin_family = AF_INET;
saddr.sin_port = htons(9999);
saddr.sin_addr.s_addr = INADDR_ANY;
int res = bind(lfd, (struct sockaddr *) &saddr, sizeof(saddr));
if(res == -1){
perror("bind");
close(lfd);
return -1;
}
// 3.设置监听
res = listen(lfd, 128);
if(res == -1){
perror("listen");
close(lfd);
return -1;
}
// 将监听的fd的状态检测委托给内核检测
int maxfd = lfd;
// 初始化检测的读集合
fd_set rdset;
fd_set rdtemp;
// 初始化
FD_ZERO(&rdset);
// 将监听的lfd设置到检测的读集合中
FD_SET(lfd, &rdset);
// 通过select委托内核检测读集合中的文件描述符状态, 检测read缓冲区有没有数据
// 如果有数据, select解除阻塞返回
// 应该让内核持续检测
while (true){
// 默认阻塞
// rdset 中是委托内核检测的所有的文件描述符
rdtemp = rdset;
int num = select(maxfd + 1, &rdtemp, nullptr, nullptr, nullptr);
// rdset中的数据被内核改写了,
// 只保留了发生变化的文件描述的标志位上的1,
// 没变化的改为0
// 只要rdset中的fd对应的标志位为1 -> 缓冲区有数据了
// 判断
// 有没有新连接
if(FD_ISSET(lfd, &rdtemp)){
sockaddr_in cliaddr{};
int len = sizeof(cliaddr);
int cfd = accept(lfd, (struct sockaddr *) &cliaddr, (socklen_t *) (&len));
char ip[32];
cout << "有客户端成功连接, 客户端ip: "
<< inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, ip, sizeof(ip))
<< ", port: "
<< ntohs(cliaddr.sin_port)
<< endl;
// 得到了有效的文件描述符
// 通信的文件描述符添加到读集合
// 在下一轮select检测的时候, 就能得到缓冲区的状态
FD_SET(cfd, &rdset);
// 更新maxfd
maxfd = max(maxfd, cfd);
}
// 再检测有没有通信的文件描述符
for (int i = 0; i <= maxfd; ++i) {
// 判断从监听的文件描述符之后到maxfd这个范围内的文件描述符是否读缓冲区有数据
if(i != lfd && FD_ISSET(i, &rdtemp)){
// 接收数据
char buf[10] = {0};
// 一次只能接收10个字节, 客户端一次发送100个字节
// 一次是接收不完的, 文件描述符对应的读缓冲区中还有数据
// 下一轮select检测的时候, 内核还会标记这个文件描述符缓冲区有数据 -> 再读一次
// 循环会一直持续, 直到缓冲区数据被读完位置
ssize_t len = read(i, buf, sizeof(buf));
if(len == 0){
cout << "客户端断开了连接..." << endl;
FD_CLR(i, &rdset);
close(i);
}
else if(len > 0){
buf[len] = '\0';
// 收到了数据
cout << "客户端: " << buf << endl;
// 数据处理
for (int j = 0; j < len; ++j) {
buf[j] = toupper(buf[j]);
}
// 发送回去
write(i, buf, len);
} else{
// 异常
perror("read");
}
}
}
}
return 0;
}
客户端代码
//
// Created by 47468 on 2024/1/24.
// client.cpp
#include "arpa/inet.h"
#include "cstdio"
#include <cstdlib>
#include "unistd.h"
#include "iostream"
using namespace std;
#include <cstring>
int main(){
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1){
perror("socket");
exit(0);
}
sockaddr_in addr;
addr.sin_family = AF_INET;
inet_pton(AF_INET, "192.168.110.129", &addr.sin_addr.s_addr);
addr.sin_port = htons(9999);
int res = connect(fd, (struct sockaddr *) &addr, sizeof(addr));
if(res == -1){
perror("connect");
close(fd);
return -1;
}
// 通信
while (true){
// 键盘输入数据
char readBuf[1024];
cout << "请输入要发送的字符串:" << endl;
cin.getline(readBuf, sizeof(readBuf));
// 把数据发送到客户端
write(fd, readBuf, strlen(readBuf));
// 再把客户端发回来的数据读出来
// 如果客户端没有发送数据, 默认阻塞
read(fd, readBuf, sizeof(readBuf));
cout << readBuf << endl;
}
close(fd);
return 0;
}
测试:
主要实现了服务器和两个客户端通信的流程, 服务器端一次最多接受10个字节, 多于10个字节后, 会分多次接收