前言

在传统单线程 TCP 服务端中,程序一次只能处理一个客户端连接,新连接或其他客户端请求会被阻塞,无法实现并发。 为了解决这个问题,Linux 提供了 IO 多路复用 技术:用单个线程同时监听多个文件描述符,哪个描述符有事件就绪,就处理哪个,以此实现多客户端并发通信。

本文主要学习 poll 多路复用,结合完整 TCP 服务端代码,从原理、代码实现、运行测试、优缺点一步步记录学习过程。

一、poll 基础概念

1. 作用

poll 是 Linux 下 IO 多路复用函数,统一监听多个文件描述符(套接字、普通文件等)的读写、异常事件。 当任意一个被监听的 fd 触发事件,poll 就会返回,程序再针对性处理。

2. 主要结构体 struct pollfd

poll 通过数组管理待监听的文件描述符,核心结构体定义:

struct pollfd {
    int   fd;         // 待监听的文件描述符
    short events;     // 想要监听的事件(由用户设置)
    short revents;    // 实际触发的事件(由内核回填)
};
  • fd:文件描述符,赋值为 -1 表示当前位置空闲,不监听;
  • events:我们主动设置要监听的事件,常用 POLLIN(读事件就绪);
  • reventspoll 返回后,内核标记该 fd 实际发生了什么事件,只读不可手动修改

3. 常用事件宏

  • POLLIN:读事件就绪,代表套接字有数据可读 / 有新客户端连接到来;
  • POLLOUT:写事件就绪;
  • POLLERR/POLLHUP:错误、连接断开事件。

4. poll 函数原型

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

参数说明

  1. fdspollfd 结构体数组,存放所有要监听的 fd;
  2. nfds:数组元素个数;
  3. timeout:超时时间,单位 毫秒
    • >0:阻塞等待指定毫秒,超时无事件则返回 0;
    • =0:非阻塞,立即返回;
    • <0:永久阻塞,直到有事件触发。

返回值:

  • 大于 0:就绪的文件描述符个数;
  • 等于 0:等待超时,无事件;
  • 小于 0:函数调用出错。

二、整体需求

编写基于 poll 的 TCP 并发服务端,实现功能:

  1. 监听本机 127.0.0.1:6000 端口;
  2. 支持多个客户端同时连接、并发收发数据;
  3. 客户端发送消息,服务端接收并回复 ok
  4. 客户端断开连接时,自动清理套接字、移出监听队列;
  5. 5 秒无事件则打印超时提示。

三、完整代码实现

服务端代码 poll_server.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <poll.h>

// 最大可监听的文件描述符数量
#define  MAXFD 10

// 初始化 TCP 监听套接字
int socket_init();
// 初始化 pollfd 数组
void fds_init(struct pollfd fds[]);
// 向 poll 数组添加待监听 fd
void fds_add(struct pollfd fds[], int fd);
// 从 poll 数组移除 fd
void fds_del(struct pollfd fds[],int fd);
// 处理新客户端连接
void  accept_client(struct pollfd fds[], int sockfd);
// 接收客户端数据并回复
void recv_data(struct pollfd fds[], int c);

void fds_init(struct pollfd fds[])
{
    for(int i = 0; i < MAXFD; i++)
    {
        fds[i].fd = -1;       // -1 代表位置空闲
        fds[i].events = 0;
        fds[i].revents = 0;
    }
}

void fds_add(struct pollfd fds[], int fd)
{
    for(int i = 0; i < MAXFD; i++)
    {
        if( fds[i].fd == -1)
        {
            fds[i].fd = fd;
            fds[i].events = POLLIN; // 监听读事件
            fds[i].revents = 0;
            break;
        }
    }
}

void fds_del(struct pollfd fds[],int fd)
{
    for(int i = 0; i < MAXFD; i++)
    {
        if( fds[i].fd == fd)
        {
            fds[i].fd = -1;
            fds[i].revents = 0;
            fds[i].events = 0;
            break;
        }
    }
}

void  accept_client(struct pollfd fds[], int sockfd)
{
    int c = accept(sockfd,NULL,NULL);
    if( c < 0)
    {
        return ;
    }
    printf("accept c=%d\n",c);
    fds_add(fds,c); // 将客户端 fd 加入监听
}

void recv_data(struct pollfd fds[], int c)
{
    char buff[128] = {0};
    int n = recv(c,buff,127,0);
    if( n <= 0 )
    {
        // 客户端断开/异常,回收资源
        fds_del(fds,c);
        close(c);
        printf("client(%d) close\n",c);
        return;
    }
    printf("recv(%d):%s\n",c,buff);
    send(c,"ok\n",3,0); // 增加换行,优化终端排版
}

int main()
{
    // 1. 创建 TCP 监听套接字
    int sockfd = socket_init();
    if( sockfd == -1)
    {
        exit(1);
    }

    // 2. 初始化 poll 数组
    struct pollfd  fds[MAXFD];
    fds_init(fds);
    fds_add(fds,sockfd); // 将监听套接字加入监听队列

    // 3. 事件循环
    while( 1 )
    {
        // 阻塞监听,超时 5000 毫秒(5秒)
        int n = poll(fds,MAXFD,5000);
        if( n == -1)
        {
            printf("poll err\n");
        }
        else if ( n == 0 )
        {
            printf("time out\n");
        }
        else
        {
            // 遍历所有 fd,处理就绪事件
            for(int i = 0; i < MAXFD; i++ )
            {
                if( fds[i].fd == -1 )
                {
                    continue;
                }
                // 判断是否是读事件就绪
                if( fds[i].revents & POLLIN)
                {
                    if( fds[i].fd == sockfd )
                    {
                        // 监听套接字就绪:有新客户端连接
                        accept_client(fds,sockfd);
                    }
                    else
                    {
                        // 客户端套接字就绪:有数据可读
                        recv_data(fds,fds[i].fd);
                    }
                }
            }
        }
    }
    return 0;
}

// TCP 服务端初始化:socket -> bind -> listen
int socket_init()
{
    int sockfd = socket(AF_INET,SOCK_STREAM,0);
    if( sockfd == -1)
    {
        return -1;
    }

    struct sockaddr_in saddr;
    memset(&saddr,0,sizeof(saddr));
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(6000);
    saddr.sin_addr.s_addr = inet_addr("127.0.0.1");

    int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
    if( res == -1)
    {
        printf("bind err\n");
        return -1;
    }

    res = listen(sockfd,5);
    if( res == -1)
    {
        return -1;
    }

    return sockfd;
}

客户端代码 client.c

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main() {
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(6000);
    addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    
    connect(sock, (struct sockaddr*)&addr, sizeof(addr));
    
    char buf[100];
    while (1) {
        printf("输入消息: ");
        fgets(buf, 100, stdin);
        send(sock, buf, strlen(buf), 0);
        
        memset(buf, 0, 100);
        recv(sock, buf, 99, 0);
        printf("服务器返回: %s", buf);
    }
    close(sock);
    return 0;
}

四、代码模块拆解讲解

1. 套接字初始化 socket_init

标准 TCP 服务端四步流程:

  1. socket():创建 TCP 流式套接字;
  2. 填充 sockaddr_in:配置 IPv4、端口 6000、本地回环地址 127.0.0.1
  3. bind():绑定 IP 和端口;
  4. listen():开启监听,等待客户端连接。

注意:htons() 作用是主机字节序转网络字节序,网络通信必须统一字节序。

2. poll 数组管理函数

  • fds_init:批量初始化数组,所有 fd 置为 -1,标记为空闲;
  • fds_add:遍历数组,找到空闲位置,添加新 fd 并设置监听 POLLIN
  • fds_del:根据 fd 找到对应位置,重置为空闲,停止监听。

3. 业务处理函数

  • accept_client:监听 fd 触发读事件 → 调用 accept 获取客户端 fd → 加入 poll 监听;
  • recv_data:客户端 fd 触发读事件 → 读取数据;
    • n <= 0:客户端断开,移除监听、关闭套接字;
    • 正常读取:打印数据,回复 ok

4. 主循环(核心逻辑)

  1. 初始化 poll 数组,把监听套接字先加入监听队列;
  2. 死循环调用 poll 阻塞等待事件,超时 5 秒;
  3. 根据 poll 返回值分情况处理:
    • 返回 0:超时,打印提示;
    • 返回 >0:有事件就绪,遍历数组区分「新连接」和「客户端数据」;
    • 返回 -1:调用出错。

五、编译与运行测试

1. 编译代码

# 编译服务端
gcc poll_server.c -o poll_server
# 编译客户端
gcc client.c -o client

2. 启动程序

终端 1 启动服务端:

./poll_server

新开多个终端,启动客户端(模拟多并发):

./client

3. 运行现象

  1. 无客户端:服务端每 5 秒打印 time out
  2. 客户端连接:服务端打印 accept c=xxx(xxx 为客户端文件描述符);
  3. 客户端发消息:服务端打印接收内容,客户端收到 ok 回复;
  4. 客户端断开:服务端打印 client(xxx) close,自动回收资源;
  5. 多客户端:多个客户端可同时通信,互不影响,实现并发。

六、poll 优缺点总结

优点

  1. 接口简单易用,逻辑清晰,上手成本低;
  2. 跨平台支持,Linux、Windows 都可使用;
  3. 相比早期 select,解除了 fd 数量的硬编码上限;

缺点

  1. 每次事件触发后,必须遍历整个 poll 数组,连接数越多,遍历开销越大;
  2. 高并发场景下性能一般,海量连接时效率不如 epoll
  3. 依然存在数组遍历的性能损耗。

七、拓展:poll 与 epoll 简单对比

技术平台遍历方式适用场景
poll跨平台全数组遍历中小规模并发、跨平台程序
epoll仅Linux只遍历就绪fd高并发服务端(主流生产方案)

八、学习总结

  1. poll 是 IO 多路复用的经典实现,核心依靠 pollfd 数组管理监听对象;
  2. 利用单个线程监听多个 fd,是实现 TCP 并发的基础方案;
  3. 完整流程:创建监听套接字 → 初始化 poll 数组 → 循环调用 poll 等待事件 → 分类处理连接与数据;
  4. poll 适合入门学习 IO 多路复用,理解原理后,可进一步学习 Linux 高性能方案 epoll

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐