第四章 Linux套接字通信:8.IO多路转接(复用)之poll

1. poll函数

poll的机制与select类似,与select在本质上没有多大差别,使用方法也类似,下面的是对于二者的对比:

  • 内核对应文件描述符的检测也是以线性的方式进行轮询,根据描述符的状态进行处理
  • poll和select检测的文件描述符集合会在检测过程中频繁的进行用户区和内核区的拷贝,它的开销随着文件描述符数量的增加而线性增大,从而效率也会越来越低。
  • select检测的文件描述符个数上限是1024,poll没有最大文件描述符数量的限制
  • select可以跨平台使用,poll只能在Linux平台使用
poll相关函数的函数原型如下:
#include <poll.h>
// 每个委托poll检测的fd都对应这样一个结构体
struct pollfd {
    int   fd;         /* 委托内核检测的文件描述符 */
    short events;     /* 委托内核检测文件描述符的什么事件 */
    short revents;    /* 文件描述符实际发生的事件 -> 传出 */
};
struct pollfd myfd[100];
// poll() 函数原型
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

[!NOTE] 函数参数:

  • fds: 这是一个struct pollfd类型的数组, 里边存储了待检测的文件描述符的信息,这个数组中有三个成员:
    • fd:委托内核检测的文件描述符
    • events:委托内核检测的fd事件(输入、输出、错误),每一个事件有多个取值
    • revents:这是一个传出参数,数据由内核写入,存储内核检测之后的结果
    • revents 是用来检查实际发生的事件的字段,而 events 是你告诉 poll() 要监听哪些事件)
  • nfds: 这是第一个参数数组中最后一个有效元素的下标 + 1(也可以指定参数1数组的元素总个数)
  • timeout: 指定poll函数的阻塞时长
    • -1:一直阻塞,直到检测的集合中有就绪的文件描述符(有事件产生)解除阻塞
    • 0:不阻塞,不管检测集合中有没有已就绪的文件描述符,函数马上返回
    • 大于0:阻塞指定的毫秒(ms)数之后,解除阻塞
  • 函数返回值:
    • 失败: 返回-1
    • 成功:返回一个大于0的整数,表示检测的集合中已就绪的文件描述符的总个数

![[Pasted image 20250317164739.png]]

2. 测试代码

服务器端:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <poll.h>
int main()
{
    // 1.创建套接字
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if(lfd == -1)
    {
        perror("socket");
        exit(0);
    }
    // 2. 绑定 ip, port
    struct sockaddr_in addr;
    addr.sin_port = htons(9999);
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    int ret = bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
    if(ret == -1)
    {
        perror("bind");
        exit(0);
    }
    // 3. 监听
    ret = listen(lfd, 100);
    if(ret == -1)
    {
        perror("listen");
        exit(0);
    }
    // 4. 等待连接 -> 循环
    // 检测 -> 读缓冲区, 委托内核去处理
    // 数据初始化, 创建自定义的文件描述符集
    struct pollfd fds[1024];
    // 初始化1024个文件描述符集;-1为未使用文件描述符
    for(int i=0; i<1024; ++i)
    {
        fds[i].fd = -1;
        fds[i].events = POLLIN;
    }
    fds[0].fd = lfd;
    int maxfd = 0;
    while(1)
    {
        // 委托内核检测
        ret = poll(fds, maxfd+1, -1);// -1:一直阻塞(监听新的客户端连接)
        if(ret == -1)
        {
            perror("poll");
            exit(0);
        }
        // 检测的度缓冲区有变化
        // 有新连接
        if(fds[0].revents & POLLIN)
        {
            // 接收连接请求
            struct sockaddr_in sockcli;
            int len = sizeof(sockcli);
            // 这个accept是不会阻塞的
            int connfd = accept(lfd, (struct sockaddr*)&sockcli, &len);
            // 委托内核检测connfd的读缓冲区
            int i;
            for(i=0; i<1024; ++i)
            {
                if(fds[i].fd == -1)
                {
                    fds[i].fd = connfd;
                    break;
                }
            }
            maxfd = i > maxfd ? i : maxfd;
        }
        // 通信, 有客户端发送数据过来
        for(int i=1; i<=maxfd; ++i)
        {
            // 如果在集合中, 说明读缓冲区有数据
            if(fds[i].revents & POLLIN)
            {
                char buf[128];
                int ret = read(fds[i].fd, buf, sizeof(buf));
                if(ret == -1)
                {
                    perror("read");
                    exit(0);
                }
                else if(ret == 0)
                {
                    printf("对方已经关闭了连接...\n");
                    close(fds[i].fd);
                    fds[i].fd = -1;
                }
                else
                {
                    printf("客户端say: %s\n", buf);
                    write(fds[i].fd, buf, strlen(buf)+1);
                }
            }
        }
    }
    close(lfd);
    return 0;
}

从上面的测试代码可以得知,使用poll和select进行IO多路转接的处理思路是完全相同的,但是使用poll编写的代码看起来会更直观一些,select使用的位图的方式来标记要委托内核检测的文件描述符(每个比特位对应一个唯一的文件描述符),并且对这个fd_set类型的位图变量进行读写还需要借助一系列的宏函数,操作比较麻烦。而poll直接将要检测的文件描述符的相关信息封装到了一个结构体struct pollfd中,我们可以直接读写这个结构体变量。
另外poll的第二个参数有两种赋值方式,但是都和第一个参数的数组有关系:

  • 使用参数1数组的元素个数
  • 使用参数1数组中存储的最后一个有效元素对应的下标值 + 1
    内核会根据第二个参数传递的值对参数1数组中的文件描述符进行线性遍历,这一点和select也是类似的。

客户端:

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

int main()
{
    // 1. 创建用于通信的套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if(fd == -1)
    {
        perror("socket");
        exit(0);
    }

    // 2. 连接服务器
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;  // ipv4
    addr.sin_port = htons(9999);   // 服务器监听的端口, 字节序应该是网络字节序
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr);
    int ret = connect(fd, (struct sockaddr*)&addr, sizeof(addr));
    if(ret == -1)
    {
        perror("connect");
        exit(0);
    }

    // 通信
    while(1)
    {
        // 读数据
        char recvBuf[1024];
        // 写数据
        // sprintf(recvBuf, "data: %d\n", i++);
        fgets(recvBuf, sizeof(recvBuf), stdin);
        write(fd, recvBuf, strlen(recvBuf)+1);
        // 如果客户端没有发送数据, 默认阻塞
        read(fd, recvBuf, sizeof(recvBuf));
        printf("recv buf: %s\n", recvBuf);
        sleep(1);
    }
    // 释放资源
    close(fd); 
    return 0;
}

客户端不需要使用IO多路转接进行处理,因为客户端和服务器的对应关系是 1:N,也就是说客户端是比较专一的,只能和一个连接成功的服务器通信。

代码分析

1. 创建监听套接字并绑定
int lfd = socket(AF_INET, SOCK_STREAM, 0);
...
int ret = bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
...
ret = listen(lfd, 100);

  • 首先创建一个套接字 lfd,然后将其绑定到指定的 IP 和端口上,并进行监听,等待客户端的连接请求。
2. 初始化 pollfd 数组
struct pollfd fds[1024];
for(int i=0; i<1024; ++i)
{
    fds[i].fd = -1;
    fds[i].events = POLLIN;
}
fds[0].fd = lfd;

  • 初始化时,所有的文件描述符的 fd 字段被设置为 -1,表示没有活动的文件描述符。events 字段设置为 POLLIN,表示关注这些文件描述符的“可读”事件
  • fds[0] 代表监听套接字 lfd它是我们首先需要监视的文件描述符
3. 使用 poll() 检查事件
ret = poll(fds, maxfd+1, -1);
  • poll() 函数会检查 fds 数组中每个文件描述符的事件。maxfd+1 表示要检查的最大文件描述符数目。-1 表示 poll() 将会无限阻塞,直到至少有一个事件发生。
4. 处理新连接(POLLIN 事件)
if(fds[0].revents & POLLIN)
{
    struct sockaddr_in sockcli;
    int len = sizeof(sockcli);
    int connfd = accept(lfd, (struct sockaddr*)&sockcli, &len);
    int i;
    for(i=0; i<1024; ++i)
    {
        if(fds[i].fd == -1)
        {
            fds[i].fd = connfd;
            break;
        }
    }
    maxfd = i > maxfd ? i : maxfd;
}

  • fds[0].revents 表示监听套接字的返回事件。如果 POLLIN 被设置,表示有新的客户端连接请求。
  • 通过 accept() 接受新的连接,将客户端的套接字 connfd 添加到 fds 数组中,以便后续进行数据交换。
  • 新连接的 connfd 会被添加到 fds 数组中,并且如果这是新添加的连接,我们需要更新 maxfd,以确保我们监视的文件描述符范围是正确的。
5. 处理客户端数据
for(int i=1; i<=maxfd; ++i)
{
    if(fds[i].revents & POLLIN)
    {
        char buf[128];
        int ret = read(fds[i].fd, buf, sizeof(buf));
        if(ret == -1)
        {
            perror("read");
            exit(0);
        }
        else if(ret == 0)
        {
            printf("对方已经关闭了连接...\n");
            close(fds[i].fd);
            fds[i].fd = -1;
        }
        else
        {
            printf("客户端say: %s\n", buf);
            write(fds[i].fd, buf, strlen(buf)+1);
        }
    }
}

  • 对于每一个已连接的客户端套接字,如果 fds[i].revents 中包含 POLLIN,则表示有数据可读。
  • 使用 read() 函数从客户端套接字读取数据。如果读取返回值为 0,表示客户端关闭了连接,close() 关闭该套接字并将其 fd 设置为 -1,表示该位置空闲;如果返回值大于 0,表示读取了数据并且我们将其回显给客户端。

[!NOTE] poll() 的工作流程

  1. 文件描述符初始化:首先创建一个文件描述符集合 fds,并将监听套接字(lfd)加入集合。
  2. 阻塞等待事件:通过 poll() 阻塞等待,直到至少有一个文件描述符变得可读或发生其他事件。
  3. 处理新连接:如果监听套接字 lfd 可读,说明有新的连接请求,调用 accept() 接受该连接,并将新的客户端套接字添加到 fds 中。
  4. 处理客户端数据:如果其他客户端的套接字可读,则表示有数据可处理,使用 read() 从客户端读取数据,并通过 write() 将数据发送回客户端。

[!tips] 为什么使用 poll() 代替 select()

  • 没有文件描述符数量限制poll() 可以监视的文件描述符数量是动态变化的,远远超过 select()FD_SETSIZE 限制(通常为 1024)。

  • 结构更简洁poll() 使用一个结构数组 pol

部分内容转载自https://subingwen.cn/linux/poll/

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值