一、为什么要用 IO 多路复用?
因为:
单线程 + 阻塞 IO 只能处理一个客户端 多进程 / 多线程太耗资源
所以需要:
一个线程,同时监视很多个 fd,哪个有数据就处理哪个 → 这就是 IO 多路复用
二、IO 模型
Linux 中常用的 IO 模型,最实用的三个:
1. 阻塞 IO(最常用)
意思:
read 等不到数据 → 一直等等数据到了 → 才返回
用过的阻塞 IO:
- TCP 的 read ()
- TCP 的 accept ()
- UDP 的 recvfrom ()
优点:
简单、稳定、绝大部分程序用的就是它
缺点:
一个 fd 阻塞,整个进程卡住你不能同时处理多个客户端。
2. 非阻塞 IO(忙等,极浪费 CPU)
意思:
read 不会等 → 没数据就立即返回报错你得自己循环不停问:"有数据没?有数据没?"
缺点:
CPU 爆高、浪费、极不推荐
3. IO 多路复用(终极方案)
一个线程 / 一个进程,同时监视多个 fd, 只要有一个 fd 有数据,我就处理它。
最大优点:
CPU 不浪费、一个线程处理大量客户端 这是 Nginx、Redis、PHP-FPM 等软件的核心模型。
三、IO 多路复用是什么?
本质:
先把 fd 交给系统内核 → 让内核帮你监视哪些 fd 有数据
数据准备好了 → 内核再通知你 → 你再去 read/write
四、IO 多路复用(select)
客户端代码
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<strings.h>
#include<unistd.h>
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <sys/time.h>
// ./client 192.168.88.129 5001
#define SERV_PORT 5001
#define SERV_IP_ADDR "192.168.88.129"
#define BUFSIZE 1024
#define QUIT_STR "QUIT"
#define SERV_RESP_STR "SERVER:"
int main(int argc,char **argv)
{
int fd = -1;
if(argc != 3)
{
exit(1);
}
int port = -1;
port = atoi(argv[2]);
struct sockaddr_in sin;
fd = socket(AF_INET,SOCK_STREAM,0);
if(fd < 0)
{
perror("socket");
exit(1);
}
bzero(&sin,sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_port = htons(port);
sin.sin_addr.s_addr = inet_addr(argv[1]);
if(connect(fd,(struct sockaddr*)&sin,sizeof(sin)) < 0)
{
perror("connect");
exit(1);
}
fd_set rset;
int maxfd = -1;
struct timeval tout;
char buf[BUFSIZE];
int ret = -1;
while(1)
{
FD_ZERO(&rset);
FD_SET(0,&rset);
FD_SET(fd,&rset);
maxfd = fd;
tout.tv_sec = 5;
tout.tv_usec = 0;
select(maxfd+1,&rset,NULL,NULL,&tout);
if(FD_ISSET(0,&rset)) //stdin have data
{
bzero(buf,BUFSIZE);
do
{
ret = read(0,buf,BUFSIZE-1);
}while(ret < 0);
if(ret < 0)
{
perror("read");
continue;
}
if(!ret) continue; //no data
if(write(fd,buf,strlen(buf))<0)
{
perror("write");
continue;
}
if(!strncasecmp(buf,QUIT_STR,strlen(QUIT_STR)))
{
printf("client is exited!\n");
break;
}
}
if(FD_ISSET(fd,&rset)) //server have data
{
bzero(buf,BUFSIZE);
do
{
ret = read(fd,buf,BUFSIZE-1);
}while(ret < 0);
if(ret < 0)
{
perror("read");
continue;
}
if(!ret) break;
printf("server said:%s\n",buf);
if((strlen(buf) > strlen(SERV_RESP_STR)) && !strncasecmp(buf,QUIT_STR,strlen(QUIT_STR)))
{
printf("client is exited!\n");
break;
}
}
}
return 0;
}
这个客户端用 select 同时监听 2 个 "数据源":
- 键盘输入(文件描述符 0)
- 服务器发来的消息(socket fd)谁有数据,就处理谁 → 不阻塞、不卡顿、双向聊天!
①定义 select 用到的变量
fd_set rset; // 文件描述符集合(select 用的"监视表")
int maxfd; // 最大的文件描述符(select 必须要)
struct timeval tout; // 超时时间
②超级核心:while 循环里的 select 四步
FD_ZERO(&rset); // 第一步:清空集合
FD_SET(0, &rset); // 把【键盘】加入监听
FD_SET(fd, &rset); // 把【服务器】加入监听
maxfd = fd; // 最大 fd 就是 socket fd
tout.tv_sec = 5; // 最多等 5 秒
// 第二步:调用 select,开始监听
select(maxfd+1, &rset, NULL, NULL, &tout);
- FD_ZERO:清空监视表
- FD_SET(0, ...):我要监听键盘
- FD_SET(fd, ...):我要监听服务器
- select(...):内核帮我盯着这两个,谁有数据我就醒过来!
③select 醒来后:判断是谁发的数据
1.键盘有输入(你打字了)
if(FD_ISSET(0, &rset))
{
read(0, buf); // 读键盘
write(fd, buf); // 发给服务器
}
-
服务器发消息来了
if(FD_ISSET(fd, &rset))
{
read(fd, buf); // 读服务器
printf("server said:%s\n", buf);
}
最关键的 3 个 select 函数(你必须背)
| 函数 | 作用 |
|---|---|
| FD_ZERO | 清空集合 |
| FD_SET | 把一个 fd 加入监听 |
| FD_ISSET | 判断这个 fd 是否有数据 |
为什么客户端要用 select?(超级重要)
不用 select:
- 你调用
read(键盘) - 程序会卡住不动
- 服务器发消息你收不到
用 select:
- 同时等键盘 + 服务器
- 谁先来,处理谁
- 不卡、不等、双向通信
select 工作流程
1. 清空集合
2. 加入键盘(0) + 服务器(fd)
3. select 阻塞等待
4. 键盘有输入 → 处理键盘
5. 服务器发消息 → 处理服务器
6. 回到第一步,循环
服务器端代码
#include<stdio.h>
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
#include <unistd.h>
#include<stdlib.h>
#include <strings.h>
#include <arpa/inet.h>
#include<string.h>
#include <pthread.h>
#include<signal.h>
#include <sys/wait.h>
#define QUIT_STR "QUIT"
#define BUFSIZE 1024
#define BACKLOG 5
#define SERV_IP 5001
#define SERV_IP_ADDR "192.168.88.129"
#define SERV_RESP_STR "SERVER:"
void child_data_handle(int signum)
{
if(SIGCHLD == signum)
{
waitpid(-1,NULL,WNOHANG);
}
}
void* client_data_handle(void* arg);
int main()
{
int fd = -1;
signal(SIGCHLD,child_data_handle);
struct sockaddr_in sin;
//1.socket
fd = socket(AF_INET,SOCK_STREAM,0);
if(fd<0)
{
perror("socket");
exit(1);
}
bzero(&sin,sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_port = htons(SERV_IP);
//sin.sin_addr.s_addr = inet_addr(SERV_IP_ADDR);
sin.sin_addr.s_addr = INADDR_ANY;
//2.bind
if(bind(fd,(struct sockaddr *)&sin,sizeof(sin)) <0)
{
perror("bind");
exit(0);
}
//3.listen
if(listen(fd,BACKLOG) < 0)
{
perror("listen");
exit(1);
}
//4.accept
pid_t pid;
int newfd = -1;
struct sockaddr_in cin;
socklen_t addrlen = sizeof(cin);
while(1)
{
newfd = accept(fd,(struct sockaddr *)&cin,&addrlen);
if(newfd < 0)
{
perror("accept");
break;
}
pid = fork();
if(pid < 0)
{
perror("fork");
break;
}
if(pid == 0)
{
char ipv4_addr[16];
if(!inet_ntop(AF_INET,(void *)&cin.sin_addr,ipv4_addr,sizeof(cin)))
{
perror("inet_ntop");
exit(1);
}
printf("Client:(%s,%d) is connect\n",ipv4_addr,ntohs(cin.sin_port));
client_data_handle(&newfd);
close(fd);
}
if(pid > 0)
{
close(newfd);
}
}
close(fd);
return 0;
}
void* client_data_handle(void* arg)
{
int newfd = *(int *)arg;
char buf[BUFSIZE];
int ret = -1;
printf("client handle process:newfd = %d\n",newfd);
char resp_buf[BUFSIZE];
while(1)
{
do
{
bzero(buf,BUFSIZE);
ret = read(newfd,buf,BUFSIZE-1);
}while(ret < 1);
if(ret < 0)
{
exit(1);
}
if(!ret)
{
break;
}
printf("receive data:%s\n",buf);
bzero(resp_buf,BUFSIZE);
strncpy(resp_buf,SERV_RESP_STR,strlen(SERV_RESP_STR));
strcat(resp_buf,buf);
do
{
ret = write(newfd,resp_buf,strlen(resp_buf));
}while(ret < 0);
if(!strncasecmp(buf,QUIT_STR,strlen(QUIT_STR)))
{
printf("Client is exiting!\n");
break;
}
}
close(newfd);
return NULL;
}
服务器这段代码:负责接收消息 + 回复消息
客户端发什么,服务器就加前缀 SERVER: 发回给你!
客户端发送代码(select 部分)
if(FD_ISSET(0,&rset)) // 你键盘输入
{
read(0,buf); // 读你输入的内容
write(fd,buf); // 发给服务器
}
服务器接收 + 处理
void* client_data_handle(void* arg)
{
int newfd = *(int *)arg; // 客户端连接的 socket
char buf[BUFSIZE];
char resp_buf[BUFSIZE];
这里 newfd 就是和客户端通信的通道
客户端 write() → 服务器 newfd 收到
①服务器读取客户端发来的消息
do
{
bzero(buf,BUFSIZE);
ret = read(newfd, buf, BUFSIZE-1);
} while(ret < 1);
对应客户端:
write(fd, buf, ...); // 客户端发
read(newfd, ...); // 服务器收
作用:
阻塞等待客户端发消息 客户端一发,服务器立刻读到 buf 里
②服务器打印收到的消息
printf("receive data:%s\n",buf);
③ 服务器拼接回复消息(重点!)
bzero(resp_buf,BUFSIZE);
strncpy(resp_buf, SERV_RESP_STR, strlen(SERV_RESP_STR));
strcat(resp_buf, buf);
SERV_RESP_STR 就是:
#define SERV_RESP_STR "SERVER:"
④服务器把拼接好的消息发回客户端
do
{
ret = write(newfd, resp_buf, strlen(resp_buf));
} while(ret < 0);
这一句直接对应客户端 select 的另一部分:
if(FD_ISSET(fd,&rset)) // 服务器有数据
{
read(fd, buf, ...); // 客户端接收服务器的回复
printf("server said:%s\n",buf);
}
