08_TCP服务器:一请求一线程 & epoll

一. TCP服务器

TCP 服务器是互联网行业公司的基础部门,不管是浏览网页还是在手机里的各种app,都需要直接或间接地访问它。

TCP 服务器是运行在某个端口上的网络程序,用于接收客户端的连接请求,并与之进行数据通信。主要负责:

  • 等待并接收客户端的连接请求
  • 与客户端建立可靠的双向通信通道
  • 持续收发数据,直到任意一方关闭连接

而作为一个基于tcp的服务器,面对海量用户的访问,高并发是一个需要考虑和重视的话题,在同一时间内可能会有数台客户端同时向服务器发起请求,如何处理这些请求呢,本文将基于C语言介绍两种经典的TCP服务器:

并发服务器:

  • 一请求一线程
  • IO多路复用(本文使用epoll)

百万并发的TCP服务器连接:将在 09_百万并发服务器进行介绍。

二. 一请求一线程

虽然这种方式在实际应用中已不被采纳,但是可以很好地帮助我们来理解并发的TCP服务器的雏形和运行过程。
我们可以把服务器看作成一个大饭店,在饭店这个场景有三位角色:

  • 前台迎宾小姐姐:listen
  • 服务员:clientfd
  • 客人:接受服务的对象

如果有客人想要进这家饭店吃饭,前台迎宾小姐姐listen就把该客人转接给专门为该客人提供服务的服务员clientfd,之后,迎宾小姐姐listen继续在门口等待下一个客人,执行同样的过程。
image
其实分解开来主要有两个过程:

1. listen

聘请一个前台迎宾的小姐姐listen,专门负责监听有无新的客人(看作IO)。
我们使用socket来实现,bind在上一节博客中已经讲过,不再赘述。
这里简单介绍一下listen()函数

int listen(int sockfd, int backlog);
//listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。

前台listen代码

int sockfd = socket(AF_INET, SOCK_STREAM, 0); //TCP连接
struct sockaddr_in addr;
memset(&addr, 0, sizeof(struct sockaddr_in)); 

addr.sin_family = AF_INET; //IPv4
addr.sin_port = htons(port); //端口
addr.sin_addr.s_addr = INADDR_ANY; //服务器本机的任意地址

if(bind(sockfd, (struct sockaddr*)&addr, sizeof(struct sockaddr_in)) < 0) { //由于是服务器,所以使用bind来建立连接
    perror("bind");
    return -2;
}
if(listen(sockfd, 5) < 0) { //前台一次最多服务5位客人(排队人数)
    perror("listen");
    return -3;
}

2. 等待新的客人

前台小姐姐一直在门口等待客人的到来(使用while 1 作为服务器的主事件),一旦来客人发送请求了,我们就专门为该客人开一个线程作为服务员clientfd,即一请求一线程。通过accept函数来对应的clientfd, 在线程的回调函数中,客户端clientfd使用recv循环接收来自listen的信息,直到读完断开连接。

这里介绍一下accpet()函数
accept函数

TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。

  • TCP客户端依次调用socket()、 connect()之后就想TCP服务器发送了一个连接请求。
  • TCP服务器监听到这个请求之后,就会调用accept () 函数取接收请求,这样连接就建立好了。

之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。(参考链接)[https://zhuanlan.zhihu.com/p/365478112]

int accept(int sock, struct sockaddr *addr, socklen_t *addrlen);

它的参数与 bind()connect() 是相同的:

  • sock 为服务器端套接字
  • addr 为 sockaddr_in 结构体变量
  • addrlen 为参数 addr 的长度,可由 sizeof() 求得。

3. 一请求一线程完整代码

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <pthread.h>

#include <unistd.h>

#define BUFFER_LENGTH 1024

void *clent_routine(void *arg) {
    int clientfd = *(int *)arg;
    while(1) {
        char buffer[BUFFER_LENGTH] = {0};
        int len = recv(clientfd, buffer, BUFFER_LENGTH, 0);
        if(len == 0) {
            //disconnect
            close(clientfd);
            break;
        } else if(len < 0) {
            //阻塞态,返回-1,通常需要设为非阻塞,但是一请求一线程的方式可以设置为阻塞
            // if(errno == EAGIN || errno == EWOULDBLOCK) {
                
            // }
            close(clientfd);
            break;
        } else {
            printf("Recv: %s %d byte(s)\n", buffer, len);
        }
    }
}
int main(int argc, char *argv[]) {
    if(argc < 2) {
        printf("Param Error\n");
        return -1;
    }

    int port = atoi(argv[1]);
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(struct sockaddr_in));

    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    addr.sin_addr.s_addr = INADDR_ANY;
    
    if(bind(sockfd, (struct sockaddr*)&addr, sizeof(struct sockaddr_in)) < 0) {
        perror("bind");
        return -2;
    }

    if(listen(sockfd, 5) < 0) {
        perror("listen");
        return -3;
    }

    while(1) {
        struct sockaddr_in client_addr;
        memset(&client_addr, 0, sizeof(struct sockaddr_in));
        socklen_t client_len = sizeof(client_addr);
        int clientfd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len); 
        pthread_t thread_id;
        pthread_create(&thread_id, NULL, clent_routine, &clientfd);
    }
    return 0;
}

代码已经完成了, 我们编译完该程序,执行该服务器, 端口指定为8888

gcc -o one_to_one TCPServer_one_to_one.c
./one_to_one 8888

接着借助网络调试助手NetAssist,在主机上使用TCP Client模式,跟服务器建立连接,并发送Xiaomo:One Request One Thread!
image

然后在服务器所在的终端上,确实看到了该条信息,恭喜你,已经实现了一个简易版的TCP服务器!
image

当然你也可以再开一个NetAssist,模拟多客户端,发现服务器也是可以收到信息的。
image
那这里有个小问题,如果有多个客户端如何区分是哪个客户端发送给服务器的?

通过sockfd是解决不了的,最简单的方法是,需要借助应用协议,也就是人为定义一个规则,这个发送信息包含client的id。
image

4. 一请求一线程的缺点

  • 这种方式虽然简单直接,但是随着客户端越来越多,比如有100万个,肯定不适合使用一请求以线程的方式, 因为在posix的线程标准下,一个thread 需要 8M 的运行内存, 那么对于服务器来说 1GB 内存, 只可以开1024/8=128个线程,显然是不能支持海量客户端的请求需求的。
  • 此外,每一个客户端都需要分配线程,带来了创建线程的开销代价。

解决方案是使用IO多路复用,接下来将通过epoll来帮助我们实现TCP服务器。

三.IO多路复用 :epoll实现TCP服务器

1. epoll是什么?

epoll 是 Linux 内核提供的一种 I/O 多路复用机制,用于高效地监控大量文件描述符(file descriptor, fd)上的事件,例如可读、可写、错误等。 它允许一个进程同时监控多个文件描述符,并在某个或某些文件描述符就绪时,通知进程。 这使得单线程的程序可以同时处理多个网络连接或其他 I/O 事件,从而提高程序的并发性能和资源利用率。
————————————————

比如一个服务器(小区),里面有很多个客户端,每个客户端都在服务器有连接(socket),每个IO相当于小区的住户收发快递
epoll是来管理这些IO,能够检测到哪个IO有数据,从而把这个提示返回给应用层,便于实现业务逻辑。这个epoll相当于小区的快递员,来检测哪个住户有快递了, 这样就能高效地管理IO, 而不像一请求一线程那样存在大量且无效摆烂的IO。I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。

epoll主要有这三个函数:
参考链接:https://zhuanlan.zhihu.com/p/17856755436

(1) epoll_create

创建一个struct eventpoll实例。

#include <sys/epoll.h>
int epoll_create(int size);

参数:size参数并没有实际意义,但一定要大于0。

返回值:成功返回epoll文件描述符;失败返回-1,并设置errno。

这是其内核源码实现:

SYSCALL_DEFINE1(epoll_create, int, size){    
    if (size <= 0) return -EINVAL; //size小于等于0,返回错误    
    return do_epoll_create(0); //传入参数没用到size
}

总结而言,epoll_create() :可以看作成,聘请一个快递员

(2) epoll_ctl

epoll_ctl函数用于向epoll实例中添加、修改或删除文件描述符,并设置这些IO关注的事件类型,如可读、可写或者有异常发生。

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数:

  • epfd‌:指向由epoll_create 创建的 epoll 实例的文件描述符。

  • op‌:表示要对目标文件描述符执行的操作,可以是以下几个值之一:

EPOLL_CTL_ADD:向 epoll 实例中添加一个新的文件描述符。
EPOLL_CTL_MOD:修改已存在文件描述符的事件类型。
EPOLL_CTL_DEL:从 epoll 实例中删除一个文件描述符。

  • fd‌:需要操作的目标文件描述符。
  • event‌:指向struct epoll_event结构的指针,该结构指定了需要监听的事件类型。可以看作是快递员接发的快递箱子。

struct epoll_event结构体:

struct epoll_event {
    uint32_t events;
    epoll_data_t data;
};
  • events‌:指定要监听的事件类型,常见事件类型包括。
EPOLLIN:表示对应的文件描述符可以读。
EPOLLOUT:表示对应的文件描述符可以写。
EPOLLRDHUP:表示对端关闭连接或者半关闭连接。
EPOLLPRI:表示有紧急数据可读(带外数据)。
EPOLLERR:表示对应的文件描述符发生错误。
EPOLLHUP:表示对应的文件描述符挂起事件。
  • data‌:用户自定义的数据

epoll_data有以下成员

typedefunion epoll_data {
    void *ptr;
    int fd;           //设置socket文件描述符
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

总结而言,epoll_ctl() 是来管理添加/关闭一个IO; 或者update一个IO从A到B

(3) epoll_wait

epoll_wait()函数用于等待在 epoll 实例上注册的文件描述符上发生的事件。这个函数会阻塞调用线程,直到有事件发生或超时。
image
如图所示,用户调用epoll_wait后,内核循环检测就绪队列是否有就绪事件,如果有就绪事件,将就绪事件返回给用户,否则继续往下执行,判断epoll是否超时,超时返回0,如果没有超时则将epoll线程挂起,epoll线程陷入休眠状态,同时插入一个epoll等待队列项。

当socket接收到数据后,会通过socket等待队列回调函数去检测epoll等待队列项,并将epoll线程唤醒,epoll线程被唤醒成功后,epoll线程再次查询就绪队列,此时就能成功返回socket事件。

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

参数:

  • epfd:epoll文件描述符。

  • events:epoll事件数组。

  • maxevents:指定events 数组的大小,即可以存储的最大事件数。

  • timeout:超时时间

    -1:表示无限等待,直到有事件发生。
    0 :表示立即返回,不等待任何事件。
    正数:表示等待的最大时间(毫秒)。

返回值: 小于0表示出错;等于0表示超时;大于0表示获取事件成功,返回就绪事件个数。

总结而言,epoll_wait()规定了等待特定的事件,以及多久时间去一次小区。

2. epoll在TCP服务器中的应用

epoll 是一种事件通知机制,它会监视一组文件描述符(包括套接字socket)的事件发生情况。

epoll_ctl这一步至关重要。它告诉epoll实例:"请开始监控sockfd这个充当服务器监听listen的文件描述符,我关心它上面的特定事件(例如EPOLLIN,表示可读事件)"

int epfd = epoll_create(1); //聘请一个快递员,这个参数只要 >0 即可
struct epoll_event events[EPOLL_SIZE] = {0}; // IO事件,快递箱子 

// 把listen_fd(sockfd)加入到epoll管理
struct epoll_event ev;
ev.events = EPOLLIN; //EPOLLIN只关注读,EPOLLOUT只关注写
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

当有事件发生时,epoll 会将相关的事件信息填充到 events 数组中,并返回给应用程序。这里的 events[i].data.fd 就是发生事件的文件描述符,当它与监听套接字 sockfd 相等时,意味着发生事件的是监听套接字,。

新连接请求的处理流程:
当客户端发起连接请求时,服务器端的监听套接字会接收到这个连接请求事件。epoll 检测到监听套接字上的连接请求事件后,就会通过 events 数组通知应用程序。
应用程序通过检查 events[i].data.fd 是否等于 sockfd,来判断是否是新的客户端连接请求。

  • 如果相等,说明就是listen操作,需要为其分配一个新的客户端,即调用 accept 函数来其分配新的客户端套接字clientfd
  • 如果不相等,说明此次epoll检测到的是之前添加过的clientfd,且检测到了clientfd当前有可读数据,我们需要为其进行类似于一请求一线程中的回调函数操作,即开始使用recv来读clientfd中的数据。

关于IO有没有数据?
当一个I/O事件(例如,新的数据到达可读,或者套接字可写)发生时,epoll会根据其配置的触发模式来通知应用程序:
水平触发与边沿触发

  • 水平触发(Level Triggered, LT):检测有无数据,可触发多次,可分多次读完

这是epoll的默认工作模式。在这种模式下,只要文件描述符上存在可用的I/O条件(例如,读缓冲区中仍有数据可读,或者写缓冲区仍有空间可写),epoll就会持续地报告该事件。即使应用程序没有一次性处理完所有数据,只要条件仍然满足,epoll会反复触发该事件,直到所有数据都被处理完毕,或者写缓冲区被填满。

  • 边沿触发(Edge Triggered, ET):检测数据从无到有(过程),只触发一次,且一次性读完

在这种模式下,epoll只会在文件描述符上的I/O条件发生变化时(即从“不可用”变为“可用”的瞬间)通知一次事件。一旦事件被通知,即使文件描述符上仍然存在可用的I/O条件,epoll也不会再次触发该事件,直到下一次I/O条件发生新的“边沿”变化。应用程序必须在一次事件通知中尽可能多地处理所有可用的数据或完成所有可写的操作,否则剩余的数据或未完成的操作将不会再次触发事件通知,可能导致数据滞留或饿死。

此外,epoll编程开发的时候,要注意sockfd、clientfd等 IO的变化有没有在epoll的集合里。

TCP服务器的主事件逻辑:

while(1) {
        int nready = epoll_wait(epfd, events, EPOLL_SIZE, 5); //(快递员, 快递盒子, 盒子多大, 多久时间去一次); -1表示只要有时间就去一次
        // nready表示有多少个IO事件
        if(nready == -1) continue;

        for (int i = 0; i < nready; i ++ ) {
            // listenfd与clientfd处理方式不一样
            // listenfd只能通过accept处理,clientfd可以通过recv处理
            if(events[i].data.fd == sockfd) { //listenfd
                struct sockaddr_in client_addr;
                memset(&client_addr, 0, sizeof(struct sockaddr_in));
                socklen_t client_len = sizeof(client_addr);
                int clientfd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len);
                
                ev.events = EPOLLIN || EPOLLET; //使用边沿触发,如果有数据,一次性读完
                ev.data.fd = clientfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
            }
            else {
                int clientfd = events[i].data.fd;
                char buffer[BUFFER_LENGTH] = {0};
                int len = recv(clientfd, buffer, BUFFER_LENGTH, 0);
                if(len < 0) { //阻塞
                    close(clientfd); //关闭IO后,记得使用epoll_ctl,delete掉
                    ev.events = EPOLLIN | EPOLLET;
                    ev.data.fd = clientfd;
                    epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev);
                    break;
                } else if(len == 0) { //disconnect
                    close(clientfd);
                    ev.events = EPOLLIN | EPOLLET;
                    ev.data.fd = clientfd;
                    epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev);
                    break;
                } else {
                    printf("Recv: %s %d byte(s)\n", buffer, len);
                }
            }
        }
    }

3. 基于epoll实现的TCP服务器完整代码

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <pthread.h>

#include <unistd.h>
#include <errno.h>
#include <fcntl.h>

#include <sys/epoll.h>

#define BUFFER_LENGTH       1024
#define EPOLL_SIZE          1024

void *client_routine(void *arg) {
    int clientfd = *(int *)arg;

    while(1) {
        char buffer[BUFFER_LENGTH] = {0};
        int len = recv(clientfd, buffer, BUFFER_LENGTH, 0);
        if(len < 0) { 
            //阻塞态,返回-1,通常需要设为非阻塞,但是一请求一线程的方式可以设置为阻塞
            // if(errno == EAGIN || errno == EWOULDBLOCK) {
                
            // }
            close(clientfd);
            break;
        } else if(len == 0) { // disconnect
            close(clientfd);
            break;
        } else {
            printf("Recv: %s, %d byte(s)\n", buffer, len);
        }
    }
}

int main(int argc, char *argv[]) {
    if(argc < 2) {
        printf("Param Error!\n");
        return -1;
    }
    int port = atoi(argv[1]);
    // 聘请一个迎宾的前台,充当listen
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(struct sockaddr_in));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    addr.sin_addr.s_addr = INADDR_ANY;

    // 前台与地址addr绑定,开始正式工作 --> listen
    if(bind(sockfd, (struct sockaddr*)&addr, sizeof(struct sockaddr_in)) < 0) {
        perror("bind");
        return -2;
    }
    
    // 前台最多只能接待五位顾客
    if(listen(sockfd, 5) < 0) {
        perror("listen");
        return -3;
    } 

#if 0
    // 一请求一线程
    while(1) {
        //来人时,前台小姐姐sockfd为客户介绍一个新的内部服务员clientfd
        struct sockaddr_in client_addr;
        memset(&client_addr, 0, sizeof(struct sockaddr_in));
        socklen_t client_len = sizeof(client_addr);

        int clientfd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len);

        pthread_t thread_id;
        pthread_create(&thread_id, NULL, client_routine, &clientfd);
    }
#else 
    int epfd = epoll_create(1); //这个参数只要 >0 即可
    // IO事件,快递箱子 
    struct epoll_event events[EPOLL_SIZE] = {0};

    // 把listen_fd(sockfd)加入到epoll管理
    struct epoll_event ev;
    ev.events = EPOLLIN; //EPOLLIN只关注读,EPOLLOUT只关注写
    ev.data.fd = sockfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

    while(1) {
        int nready = epoll_wait(epfd, events, EPOLL_SIZE, 5); //(快递员, 快递盒子, 盒子多大, 多久时间去一次); -1表示只要有时间就去一次
        // nready表示有多少个IO事件
        if(nready == -1) continue;

        for (int i = 0; i < nready; i ++ ) {
            // listenfd与clientfd处理方式不一样
            // listenfd只能通过accept处理,clientfd可以通过recv处理
            if(events[i].data.fd == sockfd) { //listenfd
                struct sockaddr_in client_addr;
                memset(&client_addr, 0, sizeof(struct sockaddr_in));
                socklen_t client_len = sizeof(client_addr);
                int clientfd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len);
                
                ev.events = EPOLLIN || EPOLLET; //使用边沿触发,如果有数据,一次性读完
                ev.data.fd = clientfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
            }
            else {
                int clientfd = events[i].data.fd;
                char buffer[BUFFER_LENGTH] = {0};
                int len = recv(clientfd, buffer, BUFFER_LENGTH, 0);
                if(len < 0) { //阻塞
                    close(clientfd); //关闭IO后,记得使用epoll_ctl,delete掉
                    ev.events = EPOLLIN | EPOLLET;
                    ev.data.fd = clientfd;
                    epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev);
                    break;
                } else if(len == 0) { //disconnect
                    close(clientfd);
                    ev.events = EPOLLIN | EPOLLET;
                    ev.data.fd = clientfd;
                    epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev);
                    break;
                } else {
                    printf("Recv: %s %d byte(s)\n", buffer, len);
                }
            }
        }
    }
#endif
    return 0;
}
posted @ 2025-11-20 13:18  Xiaomostream  阅读(4)  评论(0)    收藏  举报