这篇文章主要讲解常见的Linux网络编程模型select/poll/epoll对应接口的基础用法,侧重于服务端。
1. native模式
首先说一下最简单最朴素的模式。思路也很简单。这个是我自己起的名字,不是官方定的,面试的时候不要说native模式!
(1)创建套接字,这是一个唯一的文件描述符。函数:socket()
(2)套接字绑定服务IP和端口号。函数:bind();结构体:socketaddr_in
(3)设定端口等待连接数量上限。实际上控制的是全连接队列的长度,这个长度不能超过系统设定的队列长度最大值。函数:listen()。
(4)从监听套接字中接受客户端的连接。函数:accept().
(5)从已建立的连接中接收数据。函数:recv
(6)向客户端发送数据。函数:send()
代码:
#include <errno.h>
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <poll.h>
#include <sys/epoll.h>
int main() {
// 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 创建存储地址信息的结构体
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
servaddr.sin_port = htons(2000); // 0-1023,
// 绑定套接字到服务器地址结构的结构体
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
printf("bind failed: %s\n", strerror(errno));
}
// 设置最大等待队列长度
listen(sockfd, 10);
printf("listen finshed: %d\n", sockfd); // 3
// 存储客户端地址
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
// 接受连接
printf("accept\n");
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finshed\n");
// 从客户端接受信息
char buffer[1024] = {0};
int count = recv(clientfd, buffer, 1024, 0);
printf("RECV: %s\n", buffer);
// 向客户端发送信息
count = send(clientfd, buffer, count, 0);
printf("SEND: %d\n", count);
}
但是这么写存在一个问题,就是只能接受一个连接,发送完成数据后就自动关闭了,如果需要监听多个连接,需要使用多线程或者多进程技术改进。只需要把建立连接之后的代码替换就可以了。
void *client_thread(void *arg) {
int clientfd = *(int *)arg;
while (1) {
char buffer[1024] = {0};
int count = recv(clientfd, buffer, 1024, 0);
if (count == 0) { // disconnect
printf("client disconnect: %d\n", clientfd);
close(clientfd);
break;
}
// parser
printf("RECV: %s\n", buffer);
count = send(clientfd, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
while (1) {
printf("accept\n");
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finshed: %d\n", clientfd);
pthread_t thid;
pthread_create(&thid, NULL, client_thread, &clientfd);
}
2. select模式
native模式下,因为每次有一个连接进来,都要开一个线程或者进程进行读写事实上是非常消耗资源的。如果有成千上万的连接,线程会严重消耗cpu计算性能。因此要尽可能少的使用线程去管理连接。select就是一种在单线程模式下管理多个连接的方法。如何在单线程内管理多个连接呢?一种很自然的想法就是,不停的遍历文件描述符,有就绪的就进行操作,不就绪的就略过。这样不就可以避免多线程切换带来的开销吗。
int main()
{
// 创建一个IPv4的TCP套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 初始化服务器地址结构体
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET; // 协议族为IPv4
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定到任意IP地址
servaddr.sin_port = htons(2000); // 监听端口为2000
// 将套接字与IP地址和端口绑定
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
printf("bind failed: %s\n", strerror(errno));
}
// 开始监听,最多允许10个连接排队等待
listen(sockfd, 10);
printf("listen finished: %d\n", sockfd);
// 用于存储客户端地址信息
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
// 文件描述符集合,用于select函数监控
fd_set rfds, rset;
FD_ZERO(&rfds); // 初始化rfds为空集
FD_SET(sockfd, &rfds); // 将服务器监听套接字加入集合
int maxfd = sockfd; // 当前监控的最大文件描述符
// 主循环处理连接和通信
while (1) {
// 每次循环前复制rfds到rset,避免select直接修改原始集合
rset = rfds;
// 调用select等待文件描述符变为可读或其他状态,返回就绪的文件描述符数量
int nready = select(maxfd+1, &rset, NULL, NULL, NULL);
// 检查是否有新的连接请求
if (FD_ISSET(sockfd, &rset)) {
// 接受新的连接
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);
// 将新客户端的套接字加入监控集合
FD_SET(clientfd, &rfds);
// 更新监控的最大文件描述符
if (clientfd > maxfd) {
maxfd = clientfd;
}
}
// 遍历所有可能的客户端连接,新建立的连接下次遍历才能收发数据
for (int i = sockfd+1; i <= maxfd; i++) {
// 检查该文件描述符是否可读(有数据可接收)
if (FD_ISSET(i, &rset)) {
char buffer[1024] = {0};
// 从客户端接收数据
int count = recv(i, buffer, 1024, 0);
// 如果recv返回0,表示客户端关闭连接
if (count == 0) {
printf("client disconnect: %d\n", i);
close(i); // 关闭连接
FD_CLR(i, &rfds); // 从监控集合中移除
continue;
}
// 打印接收到的数据
printf("RECV: %s\n", buffer);
// 向客户端回送数据
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
}
3. poll模式
poll模式实际上是对select模式的改进。select函数在大多数Unix和类Unix系统中受限于一个硬编码的常量,这个常量定义了单个进程可以监视的文件描述符的最大数量。这个限制通常被称为FD_SETSIZE,它通常默认设置为一个相对有限的数量,比如1024或2048。poll使用一个结构体数组进行动态监视。
int main() {
// 创建一个IPv4的TCP套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 初始化服务器地址结构体
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET; // 协议族为IPv4
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定到本机的所有IP地址
servaddr.sin_port = htons(2000); // 服务器监听端口设为2000
// 将套接字与IP地址和端口号绑定
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
printf("bind failed: %s\n", strerror(errno));
}
// 开始监听,最多允许有10个连接请求排队等待处理
listen(sockfd, 10);
printf("listen finished: %d\n", sockfd); // 输出监听的套接字描述符
// 用于存储客户端地址信息
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
// 初始化pollfd数组用于poll函数,大小为1024,用于监控多个文件描述符
struct pollfd fds[1024] = {0};
// 将监听套接字添加到pollfd数组中,监控其可读事件
fds[sockfd].fd = sockfd;
fds[sockfd].events = POLLIN;
int maxfd = sockfd; // 当前最大的文件描述符,初始为监听套接字
// 主循环处理连接和通信
while (1) {
// 调用poll函数等待文件描述符变为可读或其他状态,-1表示无限等待
int nready = poll(fds, maxfd+1, -1);
// 检查监听套接字是否有新的连接请求
if (fds[sockfd].revents & POLLIN) {
// 接受新的连接请求
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);
// 将新连接的文件描述符添加到pollfd数组中,并设置监控其可读事件
fds[clientfd].fd = clientfd;
fds[clientfd].events = POLLIN;
// 更新最大文件描述符
if (clientfd > maxfd) maxfd = clientfd;
}
// 遍历所有可能的客户端连接
for (int i = sockfd+1; i <= maxfd;i ++) {
// 检查当前文件描述符是否有数据可读
if (fds[i].revents & POLLIN) {
char buffer[1024] = {0};
// 从客户端接收数据
int count = recv(i, buffer, 1024, 0);
// 如果recv返回0,表示客户端关闭连接
if (count == 0) {
printf("client disconnect: %d\n", i);
close(i); // 关闭连接
// 标记该文件描述符不再使用
fds[i].fd = -1;
fds[i].events = 0;
continue;
}
// 打印接收到的数据
printf("RECV: %s\n", buffer);
// 向客户端回送数据
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
}
4. epoll模式
无论是native、select还是poll,操作的都需要对文件描述符进行遍历。如果连接比较少,那么是没有问题的,如果连接非常多,每个连接需要过很长时间才能被扫描一次,这个时候用户体验会非常糟糕,服务会出现卡顿、无响应的情况,在硬实时任务中尤为致命。
有没有办法能够高效遍历连接呢?或者说能不能只对就绪的连接进行操作?一个非常自然的想法就是,当一个连接准备就绪的时候,直接把自己放到一个集合中,程序只需要遍历这个集合就可以了。epoll正是基于这种思想实现。
int main() {
// 创建一个IPv4的TCP套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 初始化服务器地址结构体
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET; // 地址族为IPv4
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定到任意IP地址
servaddr.sin_port = htons(2000); // 设置监听端口为2000
// 尝试将套接字与指定地址和端口绑定
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr))) {
printf("bind failed: %s\n", strerror(errno));
}
// 开始监听,第二个参数为监听队列的最大长度
listen(sockfd, 10);
printf("listen finished: %d\n", sockfd); // 打印监听套接字的文件描述符
// 准备用于存储客户端地址信息的结构体
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
// 创建一个epoll实例
int epfd = epoll_create(1);
// 初始化一个epoll_event结构体,用于添加监听套接字到epoll实例
struct epoll_event ev;
ev.events = EPOLLIN; // 设置事件类型为可读
ev.data.fd = sockfd; // 设置关联的文件描述符为监听套接字
// 将监听套接字添加到epoll实例中
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
// 主循环处理连接和通信
while (1) {
// 准备epoll_event数组用于存储就绪事件,最大1024个
struct epoll_event events[1024] = {0};
// 调用epoll_wait等待事件发生,-1表示无限期等待
int nready = epoll_wait(epfd, events, 1024, -1);
// 遍历所有就绪的事件
for (int i = 0; i < nready; i++) {
int connfd = events[i].data.fd; // 获取触发事件的文件描述符
// 检查是否为监听套接字事件
if (connfd == sockfd) {
// 接受新的连接请求
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);
// 初始化一个新的epoll_event,用于添加新连接到epoll
ev.events = EPOLLIN;
ev.data.fd = clientfd;
// 将新连接的文件描述符添加到epoll实例中
epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
}
// 检查是否为已连接套接字的可读事件
else if (events[i].events & EPOLLIN) {
char buffer[1024] = {0};
// 从客户端接收数据
int count = recv(connfd, buffer, 1024, 0);
if (count == 0) { // 如果接收数据为0,表示客户端关闭连接
printf("client disconnect: %d\n", connfd);
close(connfd);
// 从epoll实例中删除已关闭的连接
epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);
continue;
}
printf("RECV: %s\n", buffer);
// 向客户端发送接收到的数据
count = send(connfd, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
}
这是一条吃饭博客,由挨踢零声赞助