完整代码:
github:https://github.com/liudong-ch/c-socket
gitee:https://gitee.com/liudong_ch/c-socket
IO多路复用的系统函数
select函数原型
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
函数监视文件描述符集合,有可读、可写、异常的fd就返回,没有就阻塞。
参数:
- nfds:要监视的最大文件描述符+1。内核为每个进程分配一组文件描述符,0是标准输入,1标准输出,2标注错误。select会监听[0, nfds)的文件描述符。默认最大支持监视1024个fd。
- readfds、writefds、exceptfds:要监视的读、写、异常文件描述符的集合。函数返回时,会将可读、可写、异常的文件描述符对应的位置为1。传一个数组的地址,不使用可以设置为NULL。
- timeout:超时时间,如果超时函数会返回。
返回值:返回可以操作的文件描述符数量。
fd_set 文件描述符集合类型,操作系统提供相关的集合操作函数
#include <sys/select.h>
void FD_CLR(int fd, fd_set *set); // 清除 set 中的某一位 fd
int FD_ISSET(int fd, fd_set *set); // 如果 fd 在 set 中,返回非 0 值,否则返回 0 值
void FD_SET(int fd, fd_set *set); // 开启 set 中的 fd
void FD_ZERO(fd_set *set); // 将 set 的所有位都设置为 0
poll函数原型
#include <poll.h>
struct pollfd
{
int fd; /* 文件描述符 */
short events; /* 等待的事件 */
short revents; /* 实际发生了的事件,由内核返回 */
} ;
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数:
- fds:pollfd对象数组,对象包含fd,监视的事件,fd发生的事件。事件有下面的类型:
- nfds:pollfd数组的大小。
- timeout:超时时间。
返回值:可操作的fd的数量。
epoll函数原型
int epoll_create(int size)
创建一个epoll对象,size是监视fd的数量,好像现在没有了。返回epoll对象的文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
控制函数,设置epoll对象要做的操作。
参数:
- epfd:epoll_create返回的fd。
- op:要做的操作。系统定义有三个操作:
EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd; - fd:epoll要监视的fd。
- events:要监听的事件类型,结构如下:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
等待监视的事件发生,没有事件就阻塞。返回IO准备好的fd数量。
参数:
- epfd:epoll对象fd。
- events:结构同上,有可操作的fd,内核会将数据写入该数组中并返回。
- maxevents:events数组的长度。
- timeout:超时时间。
epoll事件有两种触发模式,水平触发(LT 默认方式)、边缘触发(ET)。
LT:每次wait(),内核会返回已经就绪的fd。如果就绪fd没有进行IO处理,下次wait()仍会通知。select、poll就是采用这种方式。容错性大,就算没有立即处理fd,wait()也会通知所有就绪fd。
ET:高速模式。每次wait(),内核返回已经就绪的fd。无论fd是否被处理,下次wait()都不会通知。提高了效率,容错小。就绪的fd,wait()只通知一次。对于没有及时处理的fd,必须在使用epoll_ctl()添加监听事件。
socket的IO多路复用是做什么的
socket程序一般为下面的流程,伪代码:
listenfd = socket(); // 打开一个网络通信端口
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(1) {
connfd = accept(listenfd); // 阻塞建立连接
while(is_close(connfd)) { // 判断客户端是否关闭连接
int n = read(connfd, buf); // 阻塞读数据
res = handle_data(buf); // 处理客户端的数据
write(connfd, res); // 将结果写入客户端的连接中
}
close(connfd); // 关闭连接
}
在几个步骤中:accept()、read() 会默认会阻塞。阻塞能节省cpu计算资源。
- accept() 阻塞等待有新的连接建立。
- read() 阻塞等待IO完成。此处IO会将网卡的数据拷贝到内核缓存中,在将内核缓存的数据拷贝到用户态缓存中。在IO没有完成所有的数据拷贝之前,程序会一直阻塞,这就是常见的阻塞IO。
该流程每次只能服务一个客户端。
第一个过程:当一个客户端建立连接后,服务端会阻塞等待接收客户端的数据。发送数据受到网速、数据大小、客户端业务什么时候发送等因素影响,可能等待很长时间。
第二个过程:读到客户端的数据后,处理数据。处理逻辑跟具体业务有关,时间可能很长。
两个过程都可能需要处理较长时间,在这段时间其他客户端建立的连接不能被accept()取出(服务端不能开始处理连接,能建立连接)。其他客户端需要等待前一个客户端完成这两个过程,才能被服务端服务。过程一是等待,服务端处于空闲状态,可以利用起来服务其他客户端。过程二是处理数据,服务器忙碌,但是可以考虑利用cpu多核并发处理其他客户端请求。
处理服务端能处理多个客户端的请求。
处理方式一
使用非阻塞的IO,将read() 设置成非阻塞的读。有数据就处理,没有数据的循环read()。
使用下面函数设置非阻塞的IO:
fcntl(connfd, F_SETFL, O_NONBLOCK);
这样处理会导致cpu的“忙等待”,服务端一直执行循环。并且还是只能处理一个客户端的数据。
处理方式二
使用多进程(或多线程)。有新连接accept(),创建一个新进程,让新进程处理这个连接。主进程继续接收新连接。新进程read()客户端的数据,处理数据,write()数据。
这种方式利用多进程方式可以处理多个客户端的请求。避免服务端等待客户端发送数据的空闲。多线程可以并发处理。
这样会有一个问题是:每个客户端创建一个进程,客户端特别多,会创建过多的进程,占用资源多,并且进程切换也有代价。还可能超过系统进程数量限制。
处理方式三
IO多路复用。就是多个IO操作复用同一个进程。常见的IO多路复用有select、poll、epoll。
思路:监听多个文件描述符fd。没有事件发生,函数会阻塞。如果有可读、可写等事件,函数会返回对应的可读或可写的fd。然后进行读、写fd,此时的fd数据一定是已经准备好的,read()函数不会阻塞。
具体代码下面实现。
IO多路复用解决了read()的阻塞,又不会造成循环“忙等待”问题。
但是,服务端在处理数据阶段仍然不能accept()取出新连接进行处理。
对于数据处理阶段,一般由应用程序处理,操作系统好像没有提供相关的方法。
应用程序会开启新线程做数据处理,主线程继续执行IO多路复用监听fd的可读、可写等事件。另一种实现思路跟IO多路复用类似,也是使用一个新线程处理多个客户端的请求,根据客户端的标识符将结果写入对应的客户端链接中。
客户端实现
客户端代码相同,实现的功能:循环 接收用户输入数据发送给服务端,然后等待接收服务端返回数据。exit退出客户端输入。核心代码段:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/socket.h>
#include <arpa/inet.h>
int start(char *ip, int port) {
int c_sock ;
struct sockaddr_in s_add;
c_sock = socket(AF_INET, SOCK_STREAM, 0);
if (c_sock == -1) {
printf("socket 创建失败\n");
exit(EXIT_FAILURE);
}
memset(&s_add, 0, sizeof(s_add));
s_add.sin_family = AF_INET;
s_add.sin_addr.s_addr = inet_addr(ip);
s_add.sin_port = htons(port);
if(connect(c_sock, (struct sockaddr *)&s_add, sizeof(s_add)) == -1) {
printf("连接失败\n");
exit(EXIT_FAILURE);
}
printf("连接成功\n");
while(1) {
char msg[2*1024];
scanf("%s", &msg[0]);
int msg_len = strlen(msg);
int recv_size;
if (strcmp(msg, "exit") == 0) {
break;
}
if (send(c_sock, msg, msg_len, 0) == -1) {
printf("发送失败...\n");
continue;
}
printf("发送成功\n");
if (strcmp(msg, "end") == 0) {
break;
}
recv_size = recv(c_sock, msg, 2*1024, 0);
if (recv_size == -1) {
printf("接收失败...\n");
continue;
}
if (recv_size == 0) {
printf("连接断开...\n");
break;
}
msg[recv_size] = '\0';
printf("接收成功:%s\n", msg);
}
close(c_sock);
return 0;
}
socket 多进程服务端
代码逻辑:
- socket – bind – listen ;基本操作
- 循环接受(accept)新的连接。接受到新连接,创建新的子进程处理连接。父进程继续accept新连接。
- 子进程循环接收数据,
- 处理数据
- 将处理完的结果发送给客户端。
- 连接断开时,子进程结束。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/wait.h>
void handle_client(int c_socket);
int multi_process(unsigned int port) {
int s_socket, c_socket;
struct sockaddr_in server_add, client_add2;
int s_add_len = sizeof(server_add);
socklen_t c_add_len = sizeof(client_add2);
pid_t pid, pp;
s_socket = socket(AF_INET, SOCK_STREAM, 0);
if (s_socket == -1) {
printf("socket 创建失败\n");
exit(EXIT_FAILURE);
}
memset(&server_add, 0, s_add_len);
server_add.sin_family = AF_INET;
server_add.sin_addr.s_addr = htonl(INADDR_ANY);
server_add.sin_port = htons(port);
int r = bind(s_socket, (struct sockaddr *)&server_add, s_add_len);
if (r == -1) {
printf("绑定socket失败--%s\n", strerror(errno));
exit(EXIT_FAILURE);
}
r = listen(s_socket, 20);
if (r == -1) {
printf("监听失败\n");
exit(EXIT_FAILURE);
}
printf("服务正在运行...\n");
while(1) {
struct sockaddr_in client_add;
c_socket = accept(s_socket, (struct sockaddr *)&client_add, &c_add_len);
if (c_socket == -1) {
printf("接受连接失败\n");
continue;
}
// 创建进程,会复制父进程的所有代码和文件描述符,
// 子进程的代码执行和父进程完全一致。子进程中 pid返回 0
pid = fork();
if (pid == -1) {
printf("创建进程失败\n");
continue;
}
if (pid == 0) { // 子进程 处理逻辑
close(s_socket);
printf("正在准备接收数据\n");
handle_client(c_socket);
break;
} else { // 父进程 处理逻辑
close(c_socket);
}
}
return 0;
}
// 连接处理函数
void handle_client(int c_socket) {
char buffer[5*1024];
int recv_size;
char msg[2*2014];
struct sockaddr_in client_add;
int c_add_len = sizeof(client_add);
getpeername(c_socket, (struct sockaddr *)&client_add, &c_add_len);
char cli_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_add.sin_addr, cli_ip, INET_ADDRSTRLEN);
while (1) {
recv_size = recv(c_socket, buffer, 5*1024, 0);
if (recv_size <= 0) {
printf("断开连接...主机地址:%s:%d\n", cli_ip, ntohs(client_add.sin_port));
break;
}
buffer[recv_size] = '\0';
if (strcmp(buffer, "end") == 0) {
printf("断开连接...主机地址:%s:%d\n", cli_ip, ntohs(client_add.sin_port));
close(c_socket);
break;
}
printf("正在处理...主机地址:%s:%d\n数据:%s\n", cli_ip, ntohs(client_add.sin_port), buffer);
sleep(5);
printf("正在返回数据...主机地址:%s:%d\n", cli_ip, ntohs(client_add.sin_port));
sleep(1);
int msg_size = sprintf(msg, "收到数据,长度:%d", recv_size);
send(c_socket, msg, msg_size, 0);
}
close(c_socket);
}
select 多路复用
poll 多路复用跟select的使用类似。
代码逻辑:
- socket – bind – listen ;基本操作 与上面相同,省略。
- 把监听新连接的socket放入集合。
- 循环 select监听文件描述符集合。
- 有可读写的socket时,遍历文件描述符集合。
- 如果文件描述符等于监听socket,说明有新连接,accept新连接并添加到文件描述符集合。
- 否则有客户端的数据到达,接收数据
- 处理数据
- 发送结果给客户端
- 连接断开还需要把socket从集合中删除。
核心代码:只展示接受新连接、接收和发送数据部分。
fd_set readfds, testfds;
FD_ZERO(&readfds);
FD_SET(s_socket, &readfds);
while(1) {
int fd;
testfds = readfds;
int ret = select(FD_SETSIZE, &testfds, (fd_set *)0, (fd_set *)0, (struct timeval *)0);
if (ret < 1) {
printf("select 失败\n");
exit(EXIT_FAILURE);
}
for (fd = 0; fd < FD_SETSIZE; fd++) {
if (FD_ISSET(fd, &testfds)) {
if (fd == s_socket) { //服务监听的socket,有新连接
struct sockaddr_in client_add;
c_socket_fd = accept(s_socket, (struct sockaddr *)&client_add, &c_add_len);
if (c_socket_fd == -1) {
printf("接受连接失败\n");
continue;
}
FD_SET(c_socket_fd, &readfds);
printf("连接成功...主机地址:%s:%d\n", inet_ntoa(client_add.sin_addr), ntohs(client_add.sin_port));
} else { //客户端的socket有数据到达
struct sockaddr_in client_add;
int c_add_len = sizeof(client_add);
getpeername(fd, (struct sockaddr *)&client_add, &c_add_len);
recv_size = recv(fd, buffer, 5*1024, 0);
if (recv_size <= 0) {
FD_CLR(fd, &readfds);
printf("断开连接...主机地址:%s:%d\n", inet_ntoa(client_add.sin_addr), ntohs(client_add.sin_port));
break;
}
buffer[recv_size] = '\0';
if (strcmp(buffer, "end") == 0) {
FD_CLR(fd, &readfds);
printf("断开连接...主机地址:%s:%d\n", inet_ntoa(client_add.sin_addr), ntohs(client_add.sin_port));
close(fd);
break;
}
printf("正在处理...主机地址:%s:%d\n数据:%s\n", inet_ntoa(client_add.sin_addr), ntohs(client_add.sin_port), buffer);
sleep(5);
printf("正在返回数据...主机地址:%s:%d\n", inet_ntoa(client_add.sin_addr), ntohs(client_add.sin_port));
sleep(1);
int msg_size = sprintf(msg, "收到数据,长度:%d", recv_size);
send(fd, msg, msg_size, 0);
}
}
}
}
epoll 多路复用
代码逻辑:
- socket – bind – listen ;基本操作 与上面相同,省略。
- 创建epoll
- 为监听连接的socket添加epoll的监听事件类型
- 循环等待epoll监听事件发生
- 有事件发生,遍历所有发生的事件
- 如果事件是连接断开,删除该事件对应的fd的epoll的监听事件类型,关闭fd
- 如果事件是新连接到达,accept新连接,添加连接fd的epoll监听事件类型
- 如果事件是客户端数据到达,读数据
- 处理数据
- 返回数据给客户端
struct epoll_event event, evlist[512];
// 创建epoll
if ((epollfd = epoll_create(512)) < 0) {
printf("epoll creat 失败\n");
exit(EXIT_FAILURE);
}
event.events = EPOLLIN | EPOLLET | EPOLLHUP;
event.data.fd = s_socket;
// 添加epoll的fd的事件监听类型
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, s_socket, &event) < 0) {
printf("epoll ctl 失败\n");
exit(EXIT_FAILURE);
}
char buffer[5*1024];
int recv_size;
char msg[2*2014];
printf("服务正在运行...\n");
while(1) {
// 等待监听的事件发生
events = epoll_wait(epollfd, evlist, 512, -1);
if (events <= 0) {
printf("epoll wait 失败\n");
continue;
}
int i;
for (i = 0; i < events; i++) {
// 连接断开、报错
if (evlist[i].events & EPOLLHUP || evlist[i].events & EPOLLERR) {
struct sockaddr_in client_add;
int c_add_len = sizeof(client_add);
getpeername(evlist[i].data.fd, (struct sockaddr *)&client_add, &c_add_len);
printf("断开连接...主机地址:%s:%d\n", inet_ntoa(client_add.sin_addr), ntohs(client_add.sin_port));
// 删除fd的事件监听,关闭fd
epoll_ctl(epollfd, EPOLL_CTL_DEL, evlist[i].data.fd, NULL);
close(evlist[i].data.fd);
continue;
} else if (evlist[i].data.fd == s_socket) { // 新连接到达
struct sockaddr_in client_add;
c_socket_fd = accept(s_socket, (struct sockaddr *)&client_add, &c_add_len);
if (c_socket_fd == -1) {
printf("接受连接失败\n");
continue;
}
event.data.fd = c_socket_fd;
event.events = EPOLLIN | EPOLLET | EPOLLHUP;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, c_socket_fd, &event) < 0) {
printf("epoll ctl 失败\n");
close(c_socket_fd);
continue;
}
printf("连接成功...主机地址:%s:%d\n", inet_ntoa(client_add.sin_addr), ntohs(client_add.sin_port));
} else if (evlist[i].events & EPOLLIN) { // 客户端,连接有可读事件
struct sockaddr_in client_add;
int c_add_len = sizeof(client_add);
getpeername(evlist->data.fd, (struct sockaddr *)&client_add, &c_add_len);
recv_size = recv(evlist->data.fd, buffer, 5*1024, 0); // 读数据
if (recv_size <= 0) { // 删除fd的事件监听,关闭fd
epoll_ctl(epollfd, EPOLL_CTL_DEL, evlist[i].data.fd, NULL);
close(evlist[i].data.fd);
printf("断开连接...主机地址:%s:%d\n", inet_ntoa(client_add.sin_addr), ntohs(client_add.sin_port));
break;
}
buffer[recv_size] = '\0';
if (strcmp(buffer, "end") == 0) {
epoll_ctl(epollfd, EPOLL_CTL_DEL, evlist[i].data.fd, NULL);
close(evlist[i].data.fd);
printf("断开连接...主机地址:%s:%d\n", inet_ntoa(client_add.sin_addr), ntohs(client_add.sin_port));
break;
}
printf("正在处理...主机地址:%s:%d\n数据:%s\n", inet_ntoa(client_add.sin_addr), ntohs(client_add.sin_port), buffer);
sleep(5);
printf("正在返回数据...主机地址:%s:%d\n", inet_ntoa(client_add.sin_addr), ntohs(client_add.sin_port));
sleep(1);
int msg_size = sprintf(msg, "收到数据,长度:%d", recv_size);
send(evlist[i].data.fd, msg, msg_size, 0); // 发送数据
}
}
}