Linux高性能服务器编程--第2篇 TCP/IP协议详解 笔记

第5章 Linux网络编程基础API

​ 从以下3方面讨论Linux网络API:

  • 1.socket地址API。socket最开始的含义是一个IP地址和端口对(ip,port),它唯一表示了使用TCP通信的一端,本书称其为socket地址。

  • 2.socket基础API。socket的主要API都定义在sys/socket.h头文件中,包括创建socket、命名socket、监听socket、接受连接、发起连接、读写数据、获取地址信息、检测带外标记、读取和设置socket选项。

  • 3.网络信息API。Linux提供了一套网络信息API,以实现主机名和IP地址之间的转换,以及服务名和端口号之间的转换,这些API都定义在netdb.h头文件中。

5.1 socket地址API

​ 先理解主机字节序和网络字节序

5.1.1 主机字节序和网络字节序

​ 现代CPU的累加器一次能装载至少4字节(32位机),即一个整型数。这4个字节在内存中排列的顺序将影响它被累加器装载成的整数的值,这就是字节序问题。

​ 字节序分为大端字节序(big endian)和小端字节序(little endian)。

​ 在大端字节序中,高位字节(Most Significant Byte,MSB)被存储在较低的内存地址,而低位字节(Least Significant Byte,LSB)被存储在较高的内存地址。在小端字节序中,高位字节(MSB)被存储在较高的内存地址,而低位字节(LSB)被存储在较低的内存地址。这就好像把多字节数据当作一个整数,低位字节在前,高位字节在后。

假设有一个32位整数0x12345678,
它以大端字节序存储在内存中如下:
地址:  0x1000   0x1001   0x1002   0x1003
数据:  0x12     0x34     0x56     0x78
以小端字节序存储在内存中如下:
地址:  0x1000   0x1001   0x1002   0x1003
数据:  0x78     0x56     0x34     0x12

​ 以下代码用于检查机器的字节序:

#include <stdio.h>
void byteorder()
{
    union
    {
        short value;
        char union_bytes[sizeof(short)];
    } test;
    test.value = 0x0102;
    if ((test.union_bytes[0] == 1) && (test.union_bytes[1] == 2))
    {
        printf("big endian\n");
    }
    else if ((test.union_bytes[0] == 2) && (test.union_bytes[1] == 1))
    {
        printf("little endian\n");
    }
    else
    {
        printf("unknown...\n");
    }
}

int main()
{
    byteorder();
    return 0;
}

现代PC大多采用小端字节序,因此小端字节序又被称为主机字节序

​ 当格式化的数据(如32 bit整型数和16 bit短整型数)在两台使用不同主机序的主机之间直接传递时,接收端会错误解释它。解决问题的方法是发送端总是把要发送的数据转化成大端字节序数据后再发送,而接收端知道对方传送过来的数据总是大端字节序,所以接收端可以根据自身采用的字节序决定是否对接收到的数据进行转换(小端机转换,大端机不转换)。因此大端字节序也称为网络字节序

​ Linux提供了以下4个函数完成主机字节序和网络字节序之间的转换:

// h: host; n: network; l: long; s: short
// long: 往往用来转换 IP; short: 往往用来转换 port
#include <netinet/in.h>
unsigned long int htonl(unsigned long int hostlong);// host to network long,将长整型(32bit)的主机字节序数据转化为网络字节序数据
unsigned short int htons(unsigned short int hostshort);
unsigned long int ntohl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);
5.1.2 通用socket地址

​ 用sockaddr 结构体表示 socket 地址

#include <bits/socket.h>	// <sys/scoket.h>
struct sockaddr {
    sa_family_t sa_family;	// 常见的地址簇有: AF_UNIX/AF_INET/AF_INET6
    char sa_data[14];	// 存放 socket 地址值,不同的地址簇地址值长度不同
}

​ sa_family成员是地址族类型(sa_family_t)的变量,地址族通常与协议族类型对应。

​ 常见的协议族(protocol family,也称domain)和对应的地址族见下表:

在这里插入图片描述

​ 宏PF_*AF_*都定义在bits/socket.h头文件中,且后者与前者有完全相同的值,因此二者通常混用。

​ sa_data成员用于存放socket地址值,但不同的协议族的地址有不同的含义和长度:

在这里插入图片描述

​ 由上表可见,14字节的sa_data成员无法容纳多数协议族的地址值,因此Linux定义了下面这个新的通用socket地址结构:

#include <bits/socket.h>
struct sockaddr_storage {
    sa_family_t sa_family;
    unsigned long int __ss_align;
    char __ss_padding[128-sizeof(__ss_align)];
}

​ sockaddr_storage结构体提供了足够大的空间用于存放地址值,而且是内存对齐的(这就是__ss_align成员的作用)。

5.1.3 专用socket地址

​ 通用socket地址结构体显然不好用,比如设置和获取IP地址和端口号需要执行繁琐的位操作,所以Linux为各个协议族提供了专门的socket地址结构。

​ UNIX本地域协议族使用以下专用socket地址结构:

#include<sys/un.h>
struct sockaddr_un{
    sa_family_t sin_family; //地址簇:AF_UNIX
    char sun_path[108]; //文件路径名
};

​ TCP/IP协议族有sockaddr_in(IPv4)和sockaddr_in6(IPv6)两个专用socket地址结构:

struct in_addr{
    u_int32_t s_addr;//IPv4地址,用网络字节序
};
struct sockaddr_in{
    sa_family_t sin_family; //地址簇:AF_INET
    u_int16_t sin_port; //端口号:要用网络字节序表示
    struct in_addr sin_addr; //IPv4地址结构体
};
struct in6_addr{
    unsigned char sa_addr[16]; //IPv6地址,要用网络字节序表示
};
struct sockaddr_in6{
    sa_family_t sin6_family; //地址簇:AF_INET6
    u_int16_t sin6_port; //端口号:要用网络字节序表示
    u_int32_t sin6_flowinfo; //流信息,应设置为0
    struct in6_addr sin6_addr; //IPv6地址结构体
    u_int32_t sin6_scope_id;//scope ID,尚处于实验阶段
};

​ 所有socket地址(包括sockaddr_storage)类型的变量在传给socket编程接口时都需要强制转换为sockaddr类型,因为所有socket编程接口使用的地址参数的类型都是sockaddr。

5.1.4 IP地址转换函数

​ Linux 提供三个「点分十进制字符串表示的 IPv4 地址和用网络字节序整数表示的 IPv4 地址之间转换」的接口

#include <arpa/inet.h>
in_addr_t inet_addr(const char* strptr); // 点分十进制字符串表示的 IPv4 地址转为网络字节序整数表示,失败返回 INADDR_NONE
int inet_aton(const char* cp, struct in_addr* inp); // 功能与inet_addr相同,但它将转换的结果存储于参数inp指向的地址结构中,失败返回 0,成功返回 1

char* inet_ntoa(struct in_addr in); // 逆向转换,注意该函数内部用一个静态变量存储转换结果,函数的返回值指向该静态内存,是不可重入

在这里插入图片描述

在这里插入图片描述

​ 更适用与 IPv4/IPv6 的函数为:

#include <arpa/inet.h>
int inet_pton(int af, const char* src, void* dst);	// af 是地址簇,可以是AF_INET或AF_INET6;src参数是字符串表示的IP地址(点分十进制表示的IPv4地址或十六进制字符串表示的IPv6地址);dst参数用于存储转换后的二进制格式地址;该函数成功时返回1,失败返回0并设置errno

// 逆向转换,cnt 可以为 #define INET_ADDRSTRLEN 16 or #define INET6_ADDRSTRLEN 46
const char* inet_ntop(int af, const void* src, char* dst, socklen_t cnt); // cnt指定dst参数指针指向的地址的长度,成功时返回目标存储单元的地址,失败返回 NULL

5.2 创建socket

​ UNIX/Linux的一个哲学是:所有东西都是文件。socket也不例外,它就是可读、可写、可控制、可关闭的描述符。

#include <sys/types.h>
#include <sys/socket.h>

// domain: 协议簇,包括 PF_INET、PF_INET6、PF_UNIX
// type: 服务类型,包括 SOCK_STREAM(TCP流服务)、SOCK_DGRAM(UDP数据报服务)
// protocol: 前两个参数已经决定了协议,一般设置为 0 即可
int socket(int domain, int type, int protocol);	// 成功时返回一个socket文件描述符,失败返回 -1

​ Linux内核版本自2.6.17起,type参数可以是服务类型和SOCK_NONBLOCK(将新建的socket设为非阻塞的)、SOCK_CLOEXEC(调用exec时关闭该描述符)标志相与的值,在此版本前,文件描述符的这两个属性需要使用额外的系统调用(如fcntl还是)来设置。

5.3 命名socket

​ 创建socket时,我们指定了地址族,但未指定使用该地址族中哪个具体socket地址,将一个socket与socket地址绑定称为给socket命名。

​ 在服务器程序中,我们通常要命名socket,因为只有命名后客户端才能知道如何连接它。客户端则通常不需要命名socket,而是采用匿名方式,即使用操作系统自动分配的socket地址。

​ 命名socket的系统调用是bind

#include <sys/types.h>
#include <sys/socket.h>

// 将 my_addr 所指的 socket 地址分配给 未命名的 sockfd 文件描述符,addrlen参数指出该socket地址的长度
int bind(int sockfd, const struct sockaddr* my_addr, socklen_t addrlen); // 失败返回 -1

bind函数成功时返回0,失败返回-1并设置errno,其中常见的errno:

  • 1.EACCES:被绑定的地址是受保护的地址,仅超级用户能访问,普通用户将socket绑定到端口0~1023上时,bind函数将返回EACCES错误。
  • 2.EADDRINUSE:被绑定的地址正在使用中,比如将socket绑定到一个处于TIME_WAIT状态的socket地址。

5.4 监听socket

​ socket被命名后,还不能接受客户连接,我们需要系统调用创建一个监听队列以存放待处理的客户连接

#include <sys/socket.h>

// sockfd参数指定被监听的socket。
// backlog 是内核监听队列的最大长度,表示服务端完全连接状态 ESTABLISHED 数量的上限(backlog+1)
// Mac 环境中测试是监听上限就是 backlog
int listen(int sockfd, int backlog); // 失败返回 -1,成功返回 0

​ 编写一个服务器程序,研究backlog参数对listen系统调用的实际影响:

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

static bool stop = false;
// SIGTERM信号的处理函数,用于结束main中的循环
static void handle_term(int sig)
{
    stop = true;
}

int main(int argc, char *argv[])
{
    signal(SIGTERM, handle_term);

    if (argc <= 3)
    {
        printf("usage: %s ip_address port_number backlog\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);
    int backlog = atoi(argv[3]);//atoi将字符串转换为整数

    int sock = socket(PF_INET, SOCK_STREAM, 0);//创建socket
    assert(sock >= 0);

    struct sockaddr_in address;
    bzero(&address, sizeof(address));//将address所指的内存区域前sizeof(address)个字节全部设为零
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);//点分十进制表示的转换为二进制格式
    address.sin_port = htons(port);//将主机的字节顺序转换为网络字节顺序

    int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));//命名socket
    assert(ret != -1);

    ret = listen(sock, backlog);//监听socket
    assert(ret != -1);

    while (!stop)
    {
        sleep(1);
    }

    close(sock);
    return 0;
}

5.5 接受连接

​ 系统调用从监听队列中接受一个连接:

#include <sys/types.h>
#include <sys/socket.h>

// sockfd 是执行过 listen 系统调用的监听 socket,处于 LISTEN 状态
// addr 用来获取被接受连接的远端 socket 地址
int accept(int sockfd, struct sockaddr* addr, socklen_t *addrlen);

​ accept 成功调用会返回一个新的连接 socket(处于 ESTABLISHED 状态),该套接字唯一标识这个被接受的连接,服务器可以通过读写该 socket 来与客户端通信

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

int main(int argc, char *argv[])
{
    if (argc <= 2)
    {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int sock = socket(PF_INET, SOCK_STREAM, 0);
    assert(sock >= 0);

    int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(sock, 5);
    assert(ret != -1);

    // 暂停 20 秒等待客户端连接和相关操作(掉线或者退出)完成
    sleep(20);

    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof(client);
    int connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);//接受连接
    if (connfd < 0)
    {
        printf("errno is: %d\n", errno);
    }
    else
    {
        // 不管客户端是掉线还是退出,服务端分别对应 ESTABLISHED 和 CLOSE_WAIT 状态
        // accept 都会从监听队列中取出连接,也就是它不会关心任何网络状况变化
        char remote[INET_ADDRSTRLEN];
        printf("connected with ip: %s and port: %d\n",
               inet_ntop(AF_INET, &client.sin_addr, remote, INET_ADDRSTRLEN), ntohs(client.sin_port));
        close(connfd);
    }

    close(sock);
    return 0;
}

5.6 发起连接

​ 服务端是通过 listen 被动接受连接,客户端就需要通过 connect 系统调用主动发起与服务端的连接:

#include <sys/types.h>
#include <sys/socket.h>

// sockfd 是客户端自己创建的套接字
// serv_addr 是服务器监听的 socket 地址
int connect(int sockfd, const struct sockaddr* serv_addr, socklen_t addrlen);

​ 成功调用返回 0,客户端的 sockfd 就唯一标识这个连接,客户端就可以通过读写 sockfd 来与服务器通信

​ 失败时返回-1并设置errno,两种常见的errno:

  • 1.ECONNREFUSED:目标端口不存在,连接被拒绝。
  • 2.ETIMEDOUT:连接超时。

5.7 关闭连接

​ 关闭一个连接实际上就是关闭该连接对应的socket,这可通过以下关闭普通文件描述符的系统调用来完成:

#include <unistd.h>
int close(int fd);	

​ fd参数是待关闭的socket,但close系统调用并不总是立即关闭一个连接,而是将fd参数的引用计数减1,只有当fd参数的引用计数为0时,才真正关闭连接

​ 多进程程序中,一次fork系统调用使父进程中打开的socket的引用计数加1,因此我们必须在父进程和子进程中都对该socket执行close函数才能将连接关闭。

​ 如果要立即终止连接,而不是将socket的引用计数减1,可用以下shutdown系统调用(相比close函数,它是专门为网络编程设计的):

#include <sys/socket.h>

// howto: 关闭读 SHUT_RD、关闭写 SHUT_WR、关闭读写 SHUT_RDWR
int shutdown(int sockfd, int howto); // 成功返回 0,失败 -1

5.8 数据读写

5.8.1 TCP数据读写

​ socket编程接口提供了专门用于socket数据读写的系统调用,它们增加了对数据读写的控制,以下系统调用用于TCP流数据读写:

#include <sys/types.h>
#include <sys/socket.h>

// buf 和 len 分别是读缓冲区的位置和大小,flags 一般为 0
// recv 成功时返回实际读取的数据长度,可能小于 len,因此我们可能要多次调用recv才能读到完整的数据
// 返回 0 表示对方已经关闭连接,-1 表示出错
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

// send 成功时返回实际写入的数据长度,失败时返回 -1
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

​ 以下代码演示MSG_OOB选项的使用,它给程序提供了发送和接收带外数据的方法,以下是发送程序:

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

int main(int argc, char *argv[])
{
    if (argc <= 2)
    {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    struct sockaddr_in server_address;
    bzero(&server_address, sizeof(server_address));
    server_address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &server_address.sin_addr);
    server_address.sin_port = htons(port);

    int sockfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(sockfd >= 0);

    // 客户端
    if (connect(sockfd, (struct sockaddr *)&server_address, sizeof(server_address)) < 0)//发起连接
    {
        printf("connection failed\n");
    }
    else
    {
        printf("send oob data out\n");
        const char *oob_data = "abc";
        const char *normal_data = "123";
        send(sockfd, normal_data, strlen(normal_data), 0);
        send(sockfd, oob_data, strlen(oob_data), MSG_OOB);
        send(sockfd, normal_data, strlen(normal_data), 0);
    }

    close(sockfd);
    return 0;
}

​ 以下是带外数据的接收程序:

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

#define BUF_SIZE 1024

int main(int argc, char *argv[])
{
    if (argc <= 2)
    {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int sock = socket(PF_INET, SOCK_STREAM, 0);
    assert(sock >= 0);

    int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(sock, 5);
    assert(ret != -1);

    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof(client);

    // 服务端
    int connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);
    if (connfd < 0)
    {
        printf("errno is: %d\n", errno);
    }
    else
    {
        char buffer[BUF_SIZE];

        memset(buffer, '\0', BUF_SIZE);
        ret = recv(connfd, buffer, BUF_SIZE - 1, 0);
        printf("got %d bytes of normal data '%s'\n", ret, buffer);

        memset(buffer, '\0', BUF_SIZE);
        ret = recv(connfd, buffer, BUF_SIZE - 1, MSG_OOB);
        printf("got %d bytes of oob data '%s'\n", ret, buffer);

        memset(buffer, '\0', BUF_SIZE);
        ret = recv(connfd, buffer, BUF_SIZE - 1, 0);
        printf("got %d bytes of normal data '%s'\n", ret, buffer);

        close(connfd);
    }

    close(sock);
    return 0;
}

​ 服务器程序的输出如下:

在这里插入图片描述

​ 由上图,客户的以MSG_OOB调用的send中,要输出的字符有3个(abc),仅有最有1个字符被服务器当成真正的带外数据接收。

​ 服务器对正常数据的接收被带外数据截断,即前一部分正常数据123ab和后续的正常数据123是不能被一个recv调用全部读出的。

5.8.2 UDP数据读写

​ socket编程接口中用于UDP数据报读写的系统调用是:

#include <sys/types.h>
#include <sys/socket.h>

// 和 recv 不同的是需要指定发送端的 socket 地址,因为 UDP 没有连接的概念
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t* addrlen);

// 指定接收端的 socket 地址
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, struct sockaddr* dest_addr, socklen_t* addrlen);
5.8.3 通用数据读写函数

​ socket编程接口还提供了一对通用的数据读写系统调用,它们不仅能用于TCP流数据,也能用于UDP数据报:

#include <sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr* msg, int flags);
sszie_t sendmsg(int sockfd, struct msghdr* msg, int flags);

​ msghdr 结构体为:

struct msghdr {
    void* msg_name;	// socket 地址
    socklen_t msg_namelen;	// 地址长度
    struct iovec* msg_iov;	// 存放数据:分散读、集中写
    int msg_iovlen; // iovec 结构体个数
    void* msg_control;
    socklen_t msg_controllen;
    int msg_flags;	// 复制函数中 flags 参数,调用过程中更新
};

​ 其中iovec结构类型指针:

struct iovec{
   void* iov_base;// 内存起始地址
   size_t iov_len;// 这块内存长度
};

5.9 带外标记

​ 在实际应用中,我们通常无法预期带外数据何时到来,好在Linux内核检测到TCP紧急标志时,将通知应用进程有带外数据需要接收。

​ 内核通知应用进程带外数据到达的常见方式是:IO复用产生异常事件和SIGURG信号。即使应用进程得到了有带外数据需要接收的通知,还需要知道带外数据在数据流中的具体位置,才能准确接收带外数据,这一点可通过以下系统调用实现:

#include <sys/socket.h>

// 判断 sockfd 是否处于带外标记,即下一个被读到的数据是否是带外数据
// 如果是返回 1,此时就可以设置 flags 为 MSG_OOB 标志的 recv 调用来接受带外数据;否则返回 0
int sockatmark(int socket);

5.10 地址信息函数

​ 有时想知道一个socket的本端socket地址,以及远端的socket地址,以下函数用于解决这个问题:

#include <sys/socket.h>

// 获取 sockfd 对应的本端 socket 地址,存在 addr 中
int getsockname(int sockfd, struct sockaddr* addr, socklen_t* addlen); // 成功返回 0,失败 -1

// 获取 sockfd 对应的远端 socket 地址,存在 addr 中
int getpeername(int sockfd, struct sockaddr* addr, socklen_t* addlen); // 成功返回 0,失败 -1

5.11 socket选项

​ 下面两个系统调用是专门用来读取和设置socket文件描述符属性的方法:

#include <sys/socket.h>

// level: 指定哪个协议,包括 SOL_SOCKET、IPPROTO_IP、IPPROTO_IPV6、IPPROTO_TCP
// option_name: 指定选项名称
// option_value 和 option_len 分别是选项的值和长度
int getsockopt(int sockfd, int level, int option_name, void* option_value,
               socketlen_t* restrict option_len); // 成功返回 0,失败 -1

int setsockopt(int sockfd, int level, int option_name, const void* option_value,
               socketlen_t option_len); // 成功返回 0,失败 -1

​ 下表中是常用的socket选项:

在这里插入图片描述

5.11.1 SO_REUSEADDR选项

​ 强制使用被处于 TIME_WAIT 状态的连接占用的 socket 地址

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

int main(int argc, char *argv[])
{
    if (argc <= 2)
    {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    int sock = socket(PF_INET, SOCK_STREAM, 0);
    assert(sock >= 0);
    // 通过 setsockopt 设置可以让处于 TIME_WAIT 状态的 socket 被重新使用
    int reuse = 1;
    setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));

    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(sock, 5);
    assert(ret != -1);

    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof(client);
    // 服务端
    int connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);
    if (connfd < 0)
    {
        printf("errno is: %d\n", errno);
    }
    else
    {
        char remote[INET_ADDRSTRLEN];
        printf("connected with ip: %s and port: %d\n",
               inet_ntop(AF_INET, &client.sin_addr, remote, INET_ADDRSTRLEN), ntohs(client.sin_port));
        close(connfd);
    }

    close(sock);
    return 0;
}

​ 我们也可通过修改内核参数/proc/sys/net/ipv4/tcp_tw_recycle来快速回收被关闭的socket,从而使TCP连接根本不进入TIME_WAIT状态,进而允许应用进程立即重用本地socket地址。

5.11.2 SO_RCVBUF和SO_SNDBUF选项

​ SO_RCVBUF和SO_SNDBUF选项分别表示TCP接收缓冲区和发送缓冲区的大小

​ 当我们用setsockopt回收来设置TCP的接收缓冲区和发送缓冲区的大小时,内核会将此值加倍,接收缓冲区加倍后的最小值为256字节,发送缓冲区加倍后的最小值为2048字节,但不同系统可能有不同最小值,如果加倍后的值还小于最小值,则将其设为最小值。最小值限制的目的主要是确保一个TCP连接拥有足够的空闲缓冲区来处理拥塞(如快速重传算法就期望TCP接收缓冲区至少容纳4个大小为MSS的TCP报文段)。

​ 可以修改内核参数/proc/sys/net/ipv4/tcp_rmem和/proc/sys/net/ipv4/tcp_wmem来强制TCP接收缓冲区和发送缓冲区的大小没有最小值限制。

​ 以下是修改发送缓冲区大小的客户端程序:

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

#define BUFFER_SIZE 512

int main(int argc, char *argv[])
{
    if (argc <= 3)
    {
        printf("usage: %s ip_address port_number send_bufer_size\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    struct sockaddr_in server_address;
    bzero(&server_address, sizeof(server_address));
    server_address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &server_address.sin_addr);
    server_address.sin_port = htons(port);

    int sock = socket(PF_INET, SOCK_STREAM, 0);
    assert(sock >= 0);

    int sendbuf = atoi(argv[3]);
    int len = sizeof(sendbuf);
    // 客户端:先设置 TCP 发送缓冲区的大小,然后立即读取它
    setsockopt(sock, SOL_SOCKET, SO_SNDBUF, &sendbuf, sizeof(sendbuf));
    getsockopt(sock, SOL_SOCKET, SO_SNDBUF, &sendbuf, (socklen_t *)&len);
    printf("the tcp send buffer size after setting is %d\n", sendbuf);

    if (connect(sock, (struct sockaddr *)&server_address, sizeof(server_address)) != -1)
    {
        char buffer[BUFFER_SIZE];
        memset(buffer, 'a', BUFFER_SIZE);
        send(sock, buffer, BUFFER_SIZE, 0);
    }

    close(sock);
    return 0;
}

​ 以下是修改接收缓冲区的服务器程序:

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

#define BUFFER_SIZE 1024

int main(int argc, char *argv[])
{
    if (argc <= 3)
    {
        printf("usage: %s ip_address port_number receive_buffer_size\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int sock = socket(PF_INET, SOCK_STREAM, 0);
    assert(sock >= 0);
    int recvbuf = atoi(argv[3]);
    int len = sizeof(recvbuf);
    // 服务端:先设置 TCP 接收缓冲区的大小,然后立即读取它
    // 不同系统最小的接受缓冲区大小不同
    setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &recvbuf, sizeof(recvbuf));
    getsockopt(sock, SOL_SOCKET, SO_RCVBUF, &recvbuf, (socklen_t *)&len);
    printf("the receive buffer size after settting is %d\n", recvbuf);

    int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(sock, 5);
    assert(ret != -1);

    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof(client);
    int connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);
    if (connfd < 0)
    {
        printf("errno is: %d\n", errno);
    }
    else
    {
        char buffer[BUFFER_SIZE];
        memset(buffer, '\0', BUFFER_SIZE);
        while (recv(connfd, buffer, BUFFER_SIZE - 1, 0) > 0)
        {
        }
        close(connfd);
    }

    close(sock);
    return 0;
}
5.11.3 SO_RCVLOWAT 和 SO_SNDLOWAT选项

​ SO_RCVLOWAT和SO_SNDLOWAT套接字选项分别表示TCP接收缓冲区和发送缓冲区的低水位标记,它们一般被IO复用系统调用用来判断socket是否可读或可写

​ 当TCP接收缓冲区中可读数据的总数大于其低水位标记时,IO复用系统调用将通知应用进程可以从对应的socket上读取数据;当TCP发送缓冲区中的空闲空间(可以写入数据的空间)大于其低水位标记时,IO复用系统调用将通知应用进程可以向对应的socket上写入数据。

默认,TCP接收缓冲区和发送缓冲区的低水位标记均为1

5.11.4 SO_LINGER选项

​ SO_LINGER选项用于控制close系统调用在关闭TCP连接时的行为。

​ 默认,当使用close系统调用关闭一个socket时,close函数将立即返回,TCP模块随后负责把该socket对应的TCP发送缓冲区中残留的数据发送给对方。

​ SO_LINGER选项对应的值的类型是linger:

#include <sys/socket.h>
struct linger{
   int l_onoff;// 开启(非0)还是关闭(0)
   int l_linger;// 滞留时间
};

根据linger结构体中两个成员的不同取值,close系统调用可能产生以下3种行为之一:

  • 1.l_onoff为0。此时SO_LINGER选项不起作用,close函数用默认行为来关闭socket。
  • 2.l_onoff不为0,l_linger为0。此时close系统调用立即返回,TCP模块将丢弃被关闭的socket对应的TCP发送缓冲区中残留的数据,同时给对方发送一个RST报文段。这种情况给服务器提供了一种异常终止一个连接的方法。
  • 3.l_onoff不为0,l_linger大于0。此时close函数的行为取决于两个条件:一是被关闭的socket对应的TCP发送缓冲区中是否还有残留的数据;二是该socket是阻塞的还是非阻塞的。对于阻塞的socket,close函数将等待长为l_linger成员的时间,直到TCP模块发送完所有残留数据并得到对方的确认,如果这段时间内TCP模块没有发送完残留数据并得到对方确认,那么close函数将返回-1并设置errno为EWOULDBLOCK。如果socket是非阻塞的,close函数将立即返回,此时我们需要根据其返回值和errno来判断数据是否已经发送完毕。

5.12 网络信息API

​ socket两个要素,即IP地址和端口号都是数值表示的,不便于记忆,也不便于扩展(如从IPv4转移到IPv6),因此前面我们用主机名访问一台机器,而避免直接使用其IP地址。同样,我们也可用服务名代替端口号。

5.12.1 gethostbyname和gethostbyaddr

​ gethostbyname函数根据主机名获取主机的完整信息,gethostbyaddr函数根据IP地址获取主机的完整信息。

#include <netdb.h>

// 根据主机名称获取主机的完整信息
struct hostent* gethostbyname(const char* name);

// 根据 IP 地址获取主机的完整信息
struct hostent* gethostbyaddr(const void* addr, size_t len, int type);

​ 以上两个函数返回的都是hostent类型的指针:

在这里插入图片描述

5.12.2 getservbyname和getservbyport

​ getservbyname函数根据名称获取某个服务的完整信息。getservbyport函数根据端口号获取某个服务的完整信息,它们实际上都是通过读取/etc/services文件来获取服务的信息。

#include <netdb.h>

// name参数指定目标服务的名字,port参数指定目标服务对应的端口号。proto参数指定服务类型,可传tcp和udp,也可传NULL表示获取所有类型的服务。
struct servent* getservbyname(const char* name, const char* proto);

struct servent* getservbyport(int port, const char* proto);

​ 以上两个函数返回的都是servent类型的指针:

在这里插入图片描述

​ 通过主机名和服务名访问目标机器上的daytime服务,以获取该机器的系统时间:

#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <stdio.h>
#include <unistd.h>
#include <assert.h>

int main(int argc, char *argv[])
{
    assert(argc == 2);
    char *host = argv[1];
    // 获取目标主机地址信息
    struct hostent *hostinfo = gethostbyname(host);
    assert(hostinfo);
    // 获取 daytime 服务信息
    struct servent *servinfo = getservbyname("daytime", "tcp");
    assert(servinfo);
    printf("daytime port is %d\n", ntohs(servinfo->s_port));

    struct sockaddr_in address;
    address.sin_family = AF_INET;
    address.sin_port = servinfo->s_port;
    // 注意这里由于 h_addr_list 已经是网络字节序了
    address.sin_addr = *(struct in_addr *)*hostinfo->h_addr_list;

    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    int result = connect(sockfd, (struct sockaddr *)&address, sizeof(address));
    assert(result != -1);

    char buffer[128];
    result = read(sockfd, buffer, sizeof(buffer));
    assert(result > 0);
    buffer[result] = '\0';
    printf("the day tiem is: %s", buffer);
    close(sockfd);
    return 0;
}
5.12.3 getaddrinfo

​ getaddrinfo函数既能通过主机名获得IP地址,也能通过服务名获得端口号,它是可重入函数.

在这里插入图片描述

​ getaddrinfo函数返回的每条结果都是一个addrinfo结构对象:

在这里插入图片描述

​ ai_protocol成员指具体的网络协议,其含义与socket系统调用的通常被设为0的第3个参数相同。ai_flags参数可以是下表中标志的按位或:

在这里插入图片描述

​ 当使用hints参数时,我们可以设置其ai_flags、ai_family、ai_socktype、ai_protocol字段,其他字段必须设为NULL,例如,下例使用hints参数获取主机ernest-laptop上的daytime流服务信息:

struct addrinfo hints;
struct addrinfo *res;

bzero(&hints, sizeof(hints));
hints.ai_socktype = SOCK_STREAM;
getaddrinfo("ernest-laptop", "daytime", &hints, &res);

​ getaddrinfo函数会隐式分配堆内存,因为res指针原本没有指向一块合法内存,所以用完这块内存后,我们需要使用以下函数释放这块内存:

在这里插入图片描述

5.12.4 getnameinfo

​ getnameinfo函数能通过socket地址结构同时获取以字符串表示的主机名和服务名

在这里插入图片描述

​ getnameinfo函数将返回的主机名存储在host参数指向的缓存中,将服务名存储在serv参数指向的缓冲中,hostlen和servlen参数分别指定这两块缓存的长度。

​ flags参数控制getnameinfo函数的行为,它可以是以下标志的逻辑或:

在这里插入图片描述

​ getaddrinfo和getnameinfo函数成功时返回0,失败则返回错误码,可能的错误码见下表:

在这里插入图片描述

​ Linux下strerror函数能将数值错误码errno转换成易读的字符串形式

在这里插入图片描述

第6章 高级I/O函数

​ Linux 提供很多高级 IO 函数,没有 read/open 等基础的常用,但是特定地方使用性能较高,一般有三类

  • 创建文件描述符的函数:pipe、dup/dup2
  • 读写数据的函数:包括 readv/writev、sendfile、mmap/munmap、splice、tee 等
  • 控制 IO 行为和属性的函数:fcntl

6.1 pipe函数

​ pipe函数用于创建一个管道,以实现进程间通信:

​ fd参数是一个包含两个int的数组。该函数成功时返回0,并将一对打开的文件描述符填入其参数指向的数组,如果失败,则返回-1并设置errno。

#include <unistd.h>

// 往 fd[1] 写入的数据可以从 fd[0] 读出,不能反过来
int pipe(int fd[2]); // 成功返回 0,并将一对打开的文件描述符填入其参数指向的数组

​ pipe函数创建的这两个文件描述符fd[0]和fd[1]分别构成管道的两端,往fd[1]写入的数据可以从fd[0]读出。

fd[0]只能用于从管道读出数据,fd[1]只能用于往管道写入数据,不能反过来使用。

​ 如果要实现双向的数据传输,就应该使用两个管道。默认,这一对文件描述符都是阻塞的。

​ 如果用read系统调用读取一个空管道,则read函数将被阻塞,直到管道内有数据可读;如果我们用write系统调用往一个满的管道中写入数据,则write函数也被阻塞,直到管道有足够的空闲空间可用。

​ 如果管道写端文件描述符fd[1]的引用计数减少到0,即没有任何进程需要往管道中写入数据,则针对该管道的读端文件描述符fd[0]的read操作将返回0,即读取到文件结束标记(EOF,End Of File);反之,如果管道的读端文件描述符fd[0]的引用计数减少至0,即没有任何进程需要从管道读取数据,则针对该管道的写端文件描述符fd[1]的write操作将失败,并引发SIGPIPE信号。

管道内部传输的数据是字节流,管道本身拥有一个容量限制,它规定如果应用进程不将数据从管道读走,该管道最多能被写入多少字节数据。自Linux 2.6.11内核起,管道容量的大小默认是65535字节,我们可用fcntl函数修改管道容量。

​ socket的基础API中有一个socketpair函数,它能创建双向管道

#include <sys/types.h>
#include <sys/socket.h>

int socketpair(int domain, int type, int protocol, int fd[2]);

​ domain参数只能使用UNIX本地域协议族AF_UNIX,因为我们仅能在本地使用这个双向管道。fd参数和pipe系统调用的参数一样,但socketpair函数创建的这对文件描述符都是既可读又可写的。socketpair函数成功时返回0,失败时返回-1并设置errno。

6.2 dup函数和dup2函数

​ 有时我们希望把标准输入重定向到一个文件,或者把标准输出重定向到一个网络连接,这可通过以下用于复制文件描述符的dup或dup2函数来实现:

在这里插入图片描述

​ dup函数创建一个新文件描述符,该新文件描述符和原有文件描述符file_descriptor参数指向相同的文件、管道、网络连接,且dup函数返回的文件描述符总是取系统当前可用的最小整数值。

​ dup2和dup函数类似,但它将新文件描述符设置为file_descriptor_two参数,返回的第一个不小于file_descriptor_two的整数值。

​ dup和dup2系统调用失败时返回-1并设置errno。

​ 以下程序使用dup函数实现了一个基本的CGI服务器:

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

int main(int argc, char *argv[])
{
    if (argc <= 2)
    {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]); //表示 ascii to integer

    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);//将点分文本的IP地址转换为二进制网络字节序的IP地址
    address.sin_port = htons(port);//将整型变量从主机字节顺序转变成网络字节顺序

    int sock = socket(PF_INET, SOCK_STREAM, 0);
    assert(sock >= 0);

    int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(sock, 5);
    assert(ret != -1);

    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof(client);
    int connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);
    if (connfd < 0)
    {
        printf("errno is: %d\n", errno);
    }
    else
    {
        close(STDOUT_FILENO); // 关闭标准输出
        dup(connfd);    // connfd 指向 1(标准输出)
        printf("abcd\n");
        close(connfd);
    }

    close(sock);
    return 0;
}

​ 与客户端通信的 socket 记为 connfd,先关闭标准输出 STDOUT_FILENO (其值为1),然后调用 dup(connfd) 返回 1,这样标准输出就和 connfd 指向同样的文件,也就是 printf 的数据直接写入管道(不会出现在终端上),发送给客户端,这就是 Comman Gateway Interface(CGI)服务器的基本工作原理

6.3 readv函数和writev 函数

​ readv函数将数据从文件描述符读到分散的内存块中,即分散读;writev函数将多块分散的内存数据一并写入文件描述符中,即集中写:

#include <sys/uio.h>

// fd 是被操作的 socket,vector 是 iovec 结构数组,iovec 结构描述的是一块内存区,count 参数是 vector 数组长度
// 成功时返回读出/写入 fd 的字节数,失败返回 -1 并设置 errno 
ssize_t readv(int fd, const struct iovec* vector, int cnt);
ssize_t writev(int fd, const struct iovec* vector, int cnt);

strcut iovec {
    void *iov_base; // 内存起始地址
    size_t iov_len; // 内存长度
};

​ 例子:HTTP 文件服务器,简单通过 writev 将 headbuf(状态行+头部字段+空行)和 filebuf(文档内容)集中写入 socket

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/uio.h>
#include <fcntl.h>
#include <libgen.h>

#define BUFFER_SIZE 1024
// 定义两种 HTTP 状态码和状态信息
static const char *status_line[2] = {"200 OK", "500 Internal server error"};

int main(int argc, char *argv[])
{
    if (argc <= 3)
    {
        printf("usage: %s ip_address port_number filename\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);
    // 将目标文件作为程序的第三个参数传入
    const char *file_name = argv[3];

    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int sock = socket(PF_INET, SOCK_STREAM, 0);
    assert(sock >= 0);

    int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(sock, 5);
    assert(ret != -1);

    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof(client);
    int connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);
    if (connfd < 0)
    {
        printf("errno is: %d\n", errno);
    }
    else
    {
        // 用于保存 HTTP 应答的状态行、头部字段和一个空行缓冲区
        char header_buf[BUFFER_SIZE];
        memset(header_buf, '\0', BUFFER_SIZE);
        char *file_buf;         // 存放目标文件内容的应用程序缓存
        struct stat file_stat;  // 用于获取目标文件的属性,比如是否为目录、文件大小等
        bool valid = true;      // 记录目标文件是否是有效文件
        int len = 0;            // 缓存区 header_buf 目前已经使用了多少字节的空间

        if (stat(file_name, &file_stat) < 0)//调用 stat()函数来获取文件属性的
        {
            // 目标文件不存在
            valid = false;
        }
        else
        {
            //判断文件是否为目录
            if (S_ISDIR(file_stat.st_mode))//st_mode是一个 32 位无符号整形数据,该变量记录了文件的类型、文件的权限等
            {
                // 目标文件是一个目录
                valid = false;
            }
            else if (file_stat.st_mode & S_IROTH)
            {
                // 当前用户有读取目标文件的权限
                // 动态分配缓存区 file_buf,并指定其大小为目标文件的大小 + 1
                // 然后将目标文件读入缓存区 file_buf
                int fd = open(file_name, O_RDONLY);
                file_buf = new char[file_stat.st_size + 1];
                memset(file_buf, '\0', file_stat.st_size + 1);
                if (read(fd, file_buf, file_stat.st_size) < 0)
                {
                    valid = false;
                }
            }
            else
            {
                valid = false;
            }
        }

        // 目标文件有效就发送正常的 HTTP 应答
        if (valid)
        {
            // HTTP 应答的状态行
            //snprintf函数可以将格式化的数据输出到一个指定长度的字符串中
            ret = snprintf(header_buf, BUFFER_SIZE - 1, "%s %s\r\n", "HTTP/1.1", status_line[0]);
            len += ret;
            // “Content-Length” 头部字段
            ret = snprintf(header_buf + len, BUFFER_SIZE - 1 - len,
                           "Content-Length: %d\r\n", file_stat.st_size);
            len += ret;
            // 空行
            ret = snprintf(header_buf + len, BUFFER_SIZE - 1 - len, "%s", "\r\n");

            // 利用 writev 将 head_buf 和 file_buf 内容一并写出
            struct iovec iv[2];
            iv[0].iov_base = header_buf;
            iv[0].iov_len = strlen(header_buf);
            iv[1].iov_base = file_buf;
            iv[1].iov_len = file_stat.st_size;
            ret = writev(connfd, iv, 2);
        }
        else
        {
            // 目标文件无效则通知客户端服务器发生了“内部错误”
            ret = snprintf(header_buf, BUFFER_SIZE - 1, "%s %s\r\n", "HTTP/1.1", status_line[1]);
            len += ret;
            ret = snprintf(header_buf + len, BUFFER_SIZE - 1 - len, "%s", "\r\n");
            send(connfd, header_buf, strlen(header_buf), 0);
        }
        close(connfd);
        delete[] file_buf;
    }

    close(sock);
    return 0;
}

6.4 sendfile函数

​ sendfile 在两个文件描述符之间直接传递数据,完全在内核中操作,避免了内核缓冲区和用户缓冲区之间的数据拷贝,这就是零拷贝

#include <sys/sendfile.h>

// in_fd --sendfile--> out_fd
// in_fd 表示待读出内容的文件描述符,out_fd 表示待写入内容的文件描述符
// offset 表示 in_fd 的起始位置,count 表示 in_fd 和 out_fd 之间传输的字节数
ssize_t sendfile(int out_fd, int in_fd, off_t* offset, size_t count); // 成功时返回传输的字节数,失败返回-1
  • in_fd 必须是一个支持类似 mmap 函数的文件描述符,必须指向真实的文件,不能是 socket 和管道
  • out_fd 必须是一个 socket

​ sendfile 几乎是专门为在网络上传输文件而设计的,以下程序利用sendfile函数将服务器上的一个文件传送给客户:

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
// #include <sys/sendfile.h> 
// Mac 中 socket.h 里包含 sendfile 函数
#include <libgen.h>

int main(int argc, char *argv[])
{
    if (argc <= 3)
    {
        printf("usage: %s ip_address port_number filename\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);
    const char *file_name = argv[3];

    int filefd = open(file_name, O_RDONLY);
    assert(filefd > 0);
    struct stat stat_buf;
    fstat(filefd, &stat_buf);

    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int sock = socket(PF_INET, SOCK_STREAM, 0);
    assert(sock >= 0);

    int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(sock, 5);
    assert(ret != -1);

    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof(client);
    int connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);
    if (connfd < 0)
    {
        printf("errno is: %d\n", errno);
    }
    else
    {
        // Linux 接口
        // sendfile(connfd, filefd, NULL, stat_buf.st_size);

        // OSX 接口
        int ret = 0;
        sendfile(filefd, connfd, NULL, (off_t *)&ret, NULL, 0);
        printf("sendfile %d bytes into client\n", ret);
        
        close(connfd);
    }

    close(sock);
    return 0;
}

​ 使用 sendfile 将服务器上的一个文件传输给客户端,其中没有为目标文件分配任何用户空间的缓存,也没有执行读取文件的操作,相比于之前的通过 HTTP 传输文件的效率要高得多

6.5 mmap函数和munmap 函数

​ mmap函数用于申请一段内存空间,我们可将这段内存作为进程间通信的共享内存,也可将文件直接映射到其中。

​ munmap函数释放由mmap函数创建的这段内存

#include <sys/mman.h>

// start: 待分配内存的起始地址,如果为 null 则系统自动分配一个地址
// length: 指定内存段的长度;prot: 设置内存段的访问权限,可以按位或取 PROT_READ|PROT_WRITE|PROT_EXEC|PROT_NONE
// flags:控制内存段内容被修改后程序的行为
// fd 是被映射文件对应的文件描述符,一般通过 open 获得
// [return] 成功时返回指向目标内存区域的指针,失败 -1
void* mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *start, size_t length); // 失败 -1,成功 0

6.6 splice函数

​ splice函数用于在两个文件描述符之间移动数据,也是零拷贝操作:

#include <fcntl.h>

// fd_in 表示待输入数据的文件描述符
// len 指定移动数据的长度
// flags 控制数据如何移动,取异或值:SPLICE_F_MOVE|SPLICE_F_NONBLOCK|SPLICE_F_MORE
ssize_t splice(int fd_in, loff_t* off_in, int fd_out, loff_t* off_out, size_t len, unsigned int flags); // 成功时返回移动字节的数量,失败 -1

​ 如果fd_in是一个管道文件描述符,那么off_in参数必须被设置为NULL,否则,off_in参数表示从输入数据流的何处开始读取数据,此时,若off_in被设置为NULL,则表示从输入数据流的当前偏移位置读入,若off_in不为NULL,则它指出具体的偏移位置。fd_out/off_out参数的含义与fd_in/off_in参数相同,不过用于输出数据流。

使用splice函数时,fd_in和fd_out必须至少有一个是管道描述符。

​ splice函数调用成功时返回移动字节的数量,它可能返回0,表示没有数据需要移动,这发生在从管道中读取数据(此时fd_in是管道文件描述符),而该管道中没有任何数据时。

​ 下面使用splice函数实现一个零拷贝的回射服务器,它将客户端发送的数据原样返回给客户端:

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <libgen.h>

int main(int argc, char *argv[])
{
    if (argc <= 2)
    {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int sock = socket(PF_INET, SOCK_STREAM, 0);
    assert(sock >= 0);

    int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(sock, 5);
    assert(ret != -1);

    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof(client);
    int connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);
    if (connfd < 0)
    {
        printf("errno is: %d\n", errno);
    }
    else
    {
        int pipefd[2];
        assert(ret != -1);
        ret = pipe(pipefd); // 创建管道

        // 将 connfd 上流入的客户数据定向到管道中: socket --> pipe[1]
        ret = splice(connfd, NULL, pipefd[1], NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
        assert(ret != -1);

        // 将管道的输出定向到 connfd 客户连接的文件描述符: pipe[0] --> socket
        ret = splice(pipefd[0], NULL, connfd, NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
        assert(ret != -1);
        
        close(connfd);
    }

    close(sock);
    return 0;
}

​ 通过splice函数将客户端的内容读入pipefd[1]中,然后再使用splice函数从pipefd[0]中读出该内容到客户端,从而实现了简单高效的回射服务,整个过程未执行recv/send函数,因此也未涉及用户空间和内核空间之间的数据拷贝。

6.7 tee函数

​ tee函数在两个管道文件描述符之间复制数据,也是零拷贝操作,它不消耗数据,因此源文件描述符上的数据仍然可以用于后续的读操作:

#include <fcntl.h>

// fd_in 和 fd_out 必须都是管道文件描述符
// [return] 成功时返回复制的字节数,失败 -1
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);

例子:实现了一个简单的 tee 程序,利用 splice(标准输入输出<–>输入输出管道) 和 tee(输出管道<–>文件管道)同时输出数据到终端和文件

#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <libgen.h>

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        printf("usage: %s <file>\n", argv[0]);
        return 1;
    }
    int filefd = open(argv[1], O_CREAT | O_WRONLY | O_TRUNC, 0666);
    assert(filefd > 0);

    int pipefd_stdout[2];
    int ret = pipe(pipefd_stdout);
    assert(ret != -1);

    int pipefd_file[2];
    ret = pipe(pipefd_file);
    assert(ret != -1);

    // close( STDIN_FILENO );
    //  dup2( pipefd_stdout[1], STDIN_FILENO );
    // write( pipefd_stdout[1], "abc\n", 4 );
    
    // 将标准输入内容输入管道 pipe_stdout[1]
    ret = splice(STDIN_FILENO, NULL, pipefd_stdout[1], NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
    assert(ret != -1);
    // 将管道 pipefd_stdout[0] 的输出复制到管道 pipe_file[1]
    ret = tee(pipefd_stdout[0], pipefd_file[1], 32768, SPLICE_F_NONBLOCK);
    assert(ret != -1);

    // 将管道 pipe_file[0] 的输出定向到文件描述符 filefd 上,从而将标准输入的内容写入文件
    ret = splice(pipefd_file[0], NULL, filefd, NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
    assert(ret != -1);
    // 将管道 pipe_stdout[0] 的输出定向到标准输出,其内容和写入文件的内容完全一致
    ret = splice(pipefd_stdout[0], NULL, STDOUT_FILENO, NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
    assert(ret != -1);

    close(filefd);
    close(pipefd_stdout[0]);
    close(pipefd_stdout[1]);
    close(pipefd_file[0]);
    close(pipefd_file[1]);
    return 0;
}

6.8 fcntl函数

​ fcntl函数正如其名字file control描述的那样,提供了对文件描述符的各种控制操作。

​ 另一个常见的控制文件描述符属性和行为的系统调用是ioctl,且ioctl函数比fcntl函数能执行更多的控制,但控制文件描述符的常用属性和操作,fcntl函数是POSIX规范指定的首选方法:

#include <fcntl.h>

// fd 是被操作的文件描述符,cmd 指定执行何种类型的操作
// 根据操作类型不同可能还需要第3个可选参数 arg
int fcntl(int fd, int cmd, ...); // 失败 -1

​ fcntl函数支持的常用操作及其参数见下表:

在这里插入图片描述

​ 网络编程中,fcntl函数通常用来将一个文件描述符设为非阻塞的:

int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;    // 返回旧的状态标志,以便日后恢复该状态标志
}

​ 此外,SIGIO和SIGURG这两个信号与其他Linux信号不同,它们必须与某个文件描述符相关联才能使用。

​ 当被关联的文件描述符可读或可写时,系统将触发SIGIO信号;当被关联的文件描述符(必须是一个socket)上有带外数据可读时,系统将触发SIGURG信号。

​ 将信号和文件描述符关联的方法是使用fcntl函数为目标文件描述符指定宿主进程或进程组,则被指定的宿主进程或进程组将捕获到这两个信号。

​ 使用SIGIO时,还需要用fcntl函数设置套接字描述符的O_ASYNC标志(异步IO标志,但SIGIO信号模型并非真正意义上的异步IO模型)。

第7章 Linux服务器程序规范

服务器程序规范,如:

  • 1.Linux服务器程序一般以后台进程形式运行,后台进程又称守护进程(daemon),它没有控制终端,因此不会意外接收到用户输入。守护进程的父进程通常是init进程(PID为1的进程)。

  • 2.Linux服务器程序通常有一套日志系统,它至少能输出日志到文件,有的高级服务器还能输出日志到专门的UDP服务器。大部分后台进程都在/var/log目录下拥有自己的日志目录。

  • 3.Linux服务器程序一般以某个专门的非root身份运行,如mysqld、httpd、syslogd等后台进程分别拥有运行账户mysql、apache、syslog。

  • 4.Linux服务器程序通常是可配置的,服务器程序通常能处理很多选项,如果选项太多,除命令行外可用配置文件来管理。绝大多数服务器程序都有配置文件,并存放在/etc目录下,如squid服务器的配置文件是/etc/squid3/squid.conf。

  • 5.Linux服务器进程通常会在启动时生成一个PID文件并存入/var/run目录中,以记录该后台进程的PID,如syslogd的PID文件是/var/run/syslogd.pid。

  • 6.Linux服务器通常需要考虑系统资源和限制,以预测自身能承受多大负荷,如进程可用文件描述符总数和内存总量等。

7.1 日志

7.1.1 Linux系统日志

​ Linux提供一个守护进程来处理系统日志,即syslogd,但现在Linux上使用的都是它的升级版rsyslogd。

rsyslogd守护进程既能接收用户进程输出的日志,又能接收内核日志。

​ 用户进程是通过调用syslog函数生成日志的,该函数将日志输出到一个UNIX本地域socket类型(AF_UNIX)的文件/dev/log中,rsyslogd则监听该文件以获取用户进程的输出。内核日志在老系统上是通过rklogd守护进程管理的,rsyslogd利用以下技术实现了同样的功能:内核日志有printk等函数打印至内核的环状缓存(ring buffer)中,环状缓存的内容直接映射到/proc/kmsg文件中,rsyslogd则通过读取该文件获得内核日志。

​ rsyslogd守护进程在接收到用户进程或内核输入的日志后,会把它们输出至特定的日志文件。

​ 默认,调试信息会保存至/var/log/debug文件,普通信息保存至/var/log/messages文件,内核消息保存至/var/log/kern.log文件,但日志信息具体如何分发,可在rsyslogd的配置文件中设置。

​ rsyslogd的主配置文件是/etc/rsyslog.conf,其中主要可以设置的内容包括:内核日志输入路径(接收来自操作系统内核的日志消息的路径),是否接收UDP日志及其监听端口(默认为514,见/etc/services文件),是否接收TCP日志及其监听端口,日志文件的权限,包含哪些子配置文件(如/etc/rsyslog.d/*.conf)。rsyslogd的子配置文件则指定各类日志的目标存储文件。

在这里插入图片描述

7.1.2 syslog函数

​ syslog函数,可以与rsyslogd守护进程通信,定义如下:

#include <syslog.h>

// priority 是设施值(LOG_USER)与日志级别的按位或
void syslog(int priority, const char* message, ...);

​ priority参数是设施值和日志级别的按位或,设施值的默认值是LOG_USER,日志级别有以下几个:

在这里插入图片描述

​ openlog 可以改变 syslog 的默认输出方式,进一步结构化日志内容

#include <syslog.h>

// ident 参数指定的字符串被添加到日志消息的日期和时间之后,一般为程序的名字
void openlog(const char* ident, int logopt, int facility);

​ ident参数指定的字符串被添加到日志消息的日期和时间之后,它通常被设置为程序的名字。

​ logopt参数对后续syslog函数的行为进行配置,它可取以下值的按位或:

在这里插入图片描述

​ facility参数修改syslog函数中的默认设施值。

​ 程序开发过程中需要输出很多调试信息,而发布之后又需要将这些调式信息关闭,这时候需要对日志进行过滤,setlogmask函数可用于设置syslog的日志掩码:

#include <syslog.h>

// 日志级别大于日志掩码的日志信息会被系统忽略
// maskpri参数指定日志掩码值
int setlogmask(int maskpri);

// 最后需要关闭日志
void closelog();

7.2 用户信息

7.2.1 UID、EUID、GID和EGID

​ 大部分服务器需要以root身份启动,但不能以root身份运行。

​ 以下函数可获取和设置当前进程的真实用户ID(UID)、有效用户ID(EUID)、真实组(GID)、有效组(EGID):

在这里插入图片描述

​ 一个进程拥有两个用户ID:UID和EUID。

EUID存在的目的是方便资源访问,它使得运行程序的用户拥有该程序的有效用户的权限

​ 如su程序,任何用户都可使用它修改自己的账户信息,但修改账户时su程序需要访问/etc/passwd文件,而访问该文件需要root权限,用ls命令可以看到,su程序的所用者是root,且它被设置了set-user-id标志,这个标志表示,任何用户运行su程序时,其有效用户id就是该程序的所有者(即root),因此根据有效用户的含义,任何运行su程序的用户都能访问/etc/passwd文件。有效用户为root的进程称为特权进程(privileged processes)。EGID的含义与EUID类似,能给运行目标程序的用户提供有效组的权限。

​ 可用以下程序测试进程的UID和EUID的区别:

#include <unistd.h>
#include <stdio.h>

int main()
{
    uid_t uid = getuid();
    uid_t euid = geteuid();
    printf("userid is %d, effective userid is: %d\n", uid, euid);
    return 0;
}

​ 将以上程序(名为test_uid)的所有者设置为root,并设置该文件的set-user-id标志,然后运行该程序以查看UID和EUID,具体操作如下:

在这里插入图片描述

​ 由上图,进程UID是启动程序的用户的ID,而EUID是root账户(文件所有者)的ID。

7.2.2 切换用户

​ 以下代码将以root身份启动的进程切换为真实用户ID运行:

#include <unistd.h>
#include <stdio.h>

static bool switch_to_user(uid_t user_id, gid_t gp_id)
{
    // 确保目标用户不是root
    if ((user_id == 0) && (gp_id == 0))
    {
        return false;
    }

    gid_t gid = getgid();
    uid_t uid = getuid();
    // 确保当前用户是合法用户:root或者目标用户
    if (((gid != 0) || (uid != 0)) && ((gid != gp_id) || (uid != user_id)))
    {
        return false;
    }
	// 如果不是root,则已经是目标用户
    if (uid != 0)
    {
        return true;
    }
	// 切换目标用户
    if ((setgid(gp_id) < 0) || (setuid(user_id) < 0))
    {
        return false;
    }

    return true;
}

7.3 进程间关系

7.3.1 进程组

​ Linux下每个进程都属于一个进程组,因此进程除了有PID信息外,还有进程组ID(PGID),可用getpgid函数获取指定进程的PGID。

​ 每个进程组都有一个首领进程,其PGID和PID相同,进程组将一直存在,直到其中所有进程都离开进程组中(终止或者加入到其他进程组),setpgid函数用于设置PGID。

#include <unistd.h>
pid_t getpgid(pid_t pid); // 成功返回 pid 的进程组的 PGID,失败 -1
int setpgid(pid_t pid, pid_t pgid); // 设置 pid 的进程组的 PGID 为 pgid,成功 0,失败 -1

一个进程只能设置自己或者子进程的 PGID,并且子进程调用 exec 系列函数之后不能再在父进程中对它设置 PGID

7.3.2 会话

​ 一些关联的进程组形成一个会话 session。

#include <unistd.h>
// 1、只能由非首领进程创建会话,
// 2、调用进程成为会话的首领,此时该进程是新会话的唯一成员
// 3、新建一个进程组,其PGID就是调用进程的PID,调用进程成为该组的首领
pid_t setsid(void);

// 读取会话ID(SID),Linux 系统认为 SID==PGID
pid_t getsid(pid_t pid); 
7.3.3 用ps命令查看进程关系

​ 可用ps命令查看进程、进程组、会话之间的关系:

在这里插入图片描述

​ 我们是在bash shell下执行ps和less命令的,所以ps和less命令的父进程是bash命令,这可从PPID(父进程PID)一列看出。

​ 这3条命令创建了一个会话(SID是1943)和2个进程组(PGID分别是1942和2298)。

​ bash命令的PID、PGID、SID都相同,说明它是会话的首领,也是组1943的首领。ps命令则是组2298的首领,因为其PID也是2298。

在这里插入图片描述

7.4 系统资源限制

​ Linux上运行的程序会受到资源限制的影响,如物理设备限制(CPU、内存等)、系统策略限制(CPU时间等)、具体实现的限制(文件名的最大长度等)。Linux系统资源限制可通过以下函数来读取或设置:

#include <sys/resource.h>
int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, const struct rlimit *rlim);

// rlim_t 是一个整数类型,描述资源级别
struct rlimit
{
    rlim_t rlim_cur; // 资源的软限制,建议性的、最好不要超越的限制,超过可能会发信号终止进程
    rlim_t rlin_max; // 资源的硬限制,软限制的上限,普通程序只能减少,只有 root 可以增加
}

​ 此外,我们可用ulimit命令修改当前shell环境下的资源限制(软限制和硬限制),这种修改将对该shell启动的所有后续程序有效,我们也可以通过修改配置文件来改变系统软限制和硬限制,且这种修改是永久的。

​ resource参数指定资源限制类型,下标列出了部分比较重要的资源限制类型:

在这里插入图片描述

7.5 改变工作目录和根目录

​ 有些服务器程序还需改变工作目录和根目录,一般,Web服务器的逻辑根目录并非文件系统的根目录“/”,而是站点的根目录(对于Linux的Web服务来说,该目录一般是/var/www)。

​ 获取进程当前工作目录和改变进程工作目录的函数:

#include <unistd.h>

// buf 指向内存用于存储进程当前工作目录的绝对路径名,size 指定其大小
// 如果当前工作目录的绝对路径长度加上结束字符\0超过了size参数,则getcwd函数将返回NULL,并将errno设为ERANGE
// 如果buf参数为NULL且size参数非0,则getcwd函数可能在内部使用malloc函数动态分配内存,并将进程的当前工作目录存储在其中,此时我们必须自己释放getcwd函数在内部创建的这块内存。
// [return] 成功时返回一个指向目标存储区的指针
char* getcwd(char* buf, size_t size); // 失败返回 NULL 并设置 errno

// path 指定要切换到的目标目录
int chdir(const char* path); // 成功 0,失败 -1

// 改变进程根目录
// path 指定要切换到的目标根目录
int chroot(const char* path); // 成功 0,失败 -1

​ chroot函数不改变进程的当前工作目录,所以调用chroot后,我们仍需使用chdir("/")将工作目录切换到新的根目录。

​ 改变进程的根目录后,我们可能无法访问类似/dev的文件或目录,因为它们并非处于新的根目录之下,但调用chroot后,进程原先打开的文件描述符依然生效,所以我们可以利用这些早先打开的文件描述符来访问调用chroot后不能直接访问的文件或目录,尤其是一些日志文件。

​ 只有特权进程才能改变根目录。

7.6 服务器程序后台化

​ 以下函数可以让一个进程以守护进程的方式运行:

#include <unistd.h>
#include <stdio.h>
#include <sys/stat.h>
#include <assert.h>
#include <sys/fcntl.h>

bool daemonize()
{
    pid_t pid = fork();//fork 函数会新生成一个进程,调用 fork 函数的进程为父进程,新生成的进程为子进程。
    if (pid < 0)
    {
        return false;
    }
    else if (pid > 0)
    {
        exit(0);//关闭父进程,这样子进程就不是进程组首进程,就可以调用setsid了
    }

    umask(0);// 设置文件权限掩码,这样当进程创建新文件时,文件的额权限将是0777

    pid_t sid = setsid();// 创建新会话,本进程将成为进程组的首领
    if (sid < 0)
    {
        return false;
    }

    if ((chdir("/")) < 0)// 切换工作目录,防止当前工作目录所在文件系统不能卸载
    {
        return false;
    }
	
    // 关闭所有文件描述符
    close(STDIN_FILENO);
    close(STDOUT_FILENO);
    close(STDERR_FILENO);
    
	// 此处省略了关闭其他已打开的文件描述符的代码

    // 将标准输入、标准输出、标准错误重定向到/dev/null文件
    open("/dev/null", O_RDONLY);
    open("/dev/null", O_RDWR);
    open("/dev/null", O_RDWR);
    return true;
}

​ Linux提供了完成以上功能的库函数:

#include <unistd.h>

// nochdir 用于指定是否改变工作目录,0 --> 工作目录设置为 "/",否则留在当前目录
// noclose 为 0 时标准输入输出以及错误输出都被重定向到 /dev/null 文件,否则依然使用原来的设备
int daemon(int nochdir, int noclose); // 成功 0,失败-1

第8章 高性能服务器程序框架

在这一章中,按照服务器程序的一般原理,将服务器解构为如下三个主要模块:

  • I/O处理单元。本章将介绍IO处理单元的四种I/O模型两种高效事件处理模式
  • 逻辑单元。本章将介绍逻辑单元的两种高效并发模式,以及高效的逻辑处理方式–有限状态机
  • 存储单元。本书不讨论存储单元,因为它只是服务器程序的可选模块,而且其内容与网络编程本身无关。

8.1 服务器模型

8.1.1 C/S模型

​ 但由于资源(视频、新闻、软件等)被数据提供者所垄断,所以几乎所有网络应用程序都采用了下图所示的C/S(客户端/服务器)模型,所有客户端都通过访问服务器来获取所需资源:

在这里插入图片描述

​ C/S模型中,服务器启动后,首先创建一个或多个监听socket,并调用bind将其绑定到服务器感兴趣的端口上,然后调用listen等待客户连接,之后客户端就可以调用connect向服务器发起连接了。

​ 由于客户连接请求是随机到达的异步事件,服务器需要使用某种IO模型来监听这一事件,图8-2中使用的是IO复用技术(select系统调用)

在这里插入图片描述

​ 当监听到连接请求后,服务器就调用accept函数接受它,并分配一个逻辑单元来服务新的连接,逻辑单元可以是新创建的子进程、子线程或其他,上图中,服务器给客户端分配的逻辑单元是由fork系统调用创建的子进程。逻辑单元读取客户请求,处理该请求,然后将处理结果返回给客户端,客户端接收到服务器反馈的结果后,可以继续向服务器发送请求,也可以立即主动关闭连接。如果客户端主动关闭连接,则服务器执行被动关闭连接。至此,双方的通信结束。

​ 服务器在处理一个客户请求的同时还会继续监听其他客户请求,否则就变成了效率低下的串行服务器(必须先处理完前一个客户的请求,才能继续处理下一个客户请求)了。

​ C/S模型适合资源相对集中的场合,且它的实现也很简单,但其缺点也很明显:服务器是通信的中心,当访问量过大时,可能所有客户都将得到很慢的响应

8.1.2 P2P模型

​ P2P(Peer to Peer,点对点)模型比C/S模型更符合网络通信的实际情况,它摒弃了以服务器为中心的格局,让网络上所有主机重新回归对等的地位,P2P模型如下图所示:

在这里插入图片描述

​ P2P模型使得每台机器在使用服务的同时也给别人提供服务,这样资源能充分、自由地共享。云计算机群(一组云计算资源的集合)可以看做P2P模型的一个典范,但P2P模型也有缺点:当用户之间传输的请求过多时,网络的负载将加重

​ 图8-3a所示的P2P模型中,主机之间很难互相发现,所以实际使用的P2P模型通常有一个专门的发现服务器,如图8-3b所示,这个发现服务器通常提供查找服务(甚至还可以提供内容服务),使每个客户都能尽快找到自己需要的资源。

​ 从编程角度讲,P2P模型可看作C/S模型的扩展,每台主机既是客户端,又是服务器。

8.2 服务器编程框架

​ 虽然服务器程序种类繁多,但其基本框架都一样,不同之处在于逻辑处理,基本框架见下图

在这里插入图片描述

​ 上图既能用来描述一台服务器,也能用来描述一个服务器机群,两种情况下各个部件的含义和功能见下表:

在这里插入图片描述

在这里插入图片描述

IO处理单元是服务器管理客户连接的模块,它通常要完成以下工作:等待并接受新的客户连接,接收客户数据,将服务器响应数据返回给客户端。但数据的收发不一定在IO处理单元中执行,也可能在逻辑单元中执行,具体在何处执行取决于事件处理模式。对一个服务器机群来说,IO处理单元是一个专门的接入服务器,它实现负载均衡,从所有逻辑服务器中选取符合最小的一台来为新客户服务。

一个逻辑单元通常是一个进程或线程,它分析并处理客户数据,然后将结果传递给IO处理单元或直接发送给客户端(取决于事件处理模式)。对服务器机群而言,一个逻辑单元本身就是一台逻辑服务器,服务器通常拥有多个逻辑单元,以实现对多个客户任务的并行处理。

网络存储单元可以是数据库、缓存、文件,甚至是一台独立的服务器。但它不是必须得,如ssh、telnet等登录服务器就不需要这个单元。

请求队列是各个单元之间的通信方式的抽象,IO处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求,同样,多个逻辑单元同时访问一个存储单元时,也需要某种机制来协调处理竞态条件。请求队列通常被实现为池的一部分。对于服务器机群而言,请求队列是各台服务器之间预先建立的、静态的、永久的TCP连接,这种TCP连接能提高服务器之间交换数据的效率,因为它避免了动态建立TCP连接导致的额外的系统开销。

8.3 I/O模型

socket在创建的时候默认是阻塞的,我们可以给socket系统调用的第2个参数传递SOCK_NONBLOCK标志,或通过fcntl系统调用的F_SETFL命令,将其设置为非阻塞的。

​ 阻塞和非阻塞的概念能应用于所有文件描述符,而不仅仅是socket。我们称阻塞的文件描述符为阻塞IO,称非阻塞的文件描述符为非阻塞IO。

针对阻塞IO执行的系统调用可能因为无法立即完成而非操作系统挂起,直到等待的事件发生为止,比如,客户端通过connect函数向服务器发起连接时,connect函数首先发送同步报文段给服务器,然后等待服务器返回确认报文段,如果服务器的确认报文端没有立即到达客户端,则connect函数将被挂起,直到客户端收到确认报文段并唤醒connect函数。socket的基础API中,可能被阻塞的系统调用包括accept、send、recv、connect。

针对非阻塞IO执行的系统调用总是立即返回,而不管事件是否已经发生。如果事件没有立即发生,这些系统调用返回-1,这和出错返回相同,此时我们要根据errno来区分是出错还是非阻塞情况,对accept、send、recv函数而言,事件未发生时errno通常被设置成EAGAIN(意为再来一次)或者EWOULDBLOCK(意为期望阻塞),对connect函数而言,errno则被设置成EINPROGRESS(意为在处理中)。

​ 我们只有在事件已经发生的情况下操作非阻塞IO(读、写等),才能提高程序的效率,因此,非阻塞IO通常要和其他IO通知机制一起使用,比如IO复用和SIGIO信号。

IO复用是最常使用的IO通知机制,它指的是,应用进程通过IO复用函数向内核注册一组事件,内核通过IO复用函数把其中就绪的事件通知给应用程序。Linux上常用的IO复用函数是select、poll、epoll_wait。IO复用函数本身是阻塞的,它能提高程序效率的原因在于它们具有同时监听多个IO事件的能力。

SIGIO信号也能用来报告IO事件,我们可以为一个目标文件描述符指定宿主进程,被指定的宿主进程将捕获到SIGIO信号,这样,当目标文件描述符上有事件发生时,SIGIO信号的信号处理函数将被触发,我们就能在该信号处理函数中对目标文件描述符执行非阻塞IO操作了

​ 理论上,阻塞IO、IO复用、信号驱动IO都是同步IO模型,因为在这三种IO模型中,IO的读写操作,都是在IO事件发生之后,由应用进程来完成的。而POSIX规范所定义的异步IO模型中,用户可以直接对IO执行读写操作,这些操作告诉内核用户读写缓冲区的位置,以及IO操作完成后内核通知应用进程的方式。

​ 异步IO的读写操作总是立即返回,而不论IO是否是阻塞的,因为真正的读写操作已经由内核接管。同步IO模型要求用户代码自行执行IO操作(将数据从内核缓冲区读入用户缓冲区,或将数据从用户缓冲区写入内核缓冲区),而异步IO机制则由内核来执行IO操作(数据在内核缓冲区和用户缓冲区之间的移动是由内核在“后台”完成的)。同步IO向应用进程通知的是IO就绪事件,而异步IO向应用进程通知的是IO完成事件。Linux环境下,aio.h头文件中定义的函数提供了对异步IO的支持。

在这里插入图片描述

8.4 两种高效的事件处理模式

​ 服务器进程通常需要处理三类事件:IO事件、信号、定时事件。我们先整体介绍一下两种高效的事件处理模式Reactor和Proactor。

​ 同步IO模型通常用于实现Reactor模式,异步IO模型则用于实现Proactor模式,也可用同步IO方式模拟出Proactor模式。

8.4.1 Reactor模式

​ Reactor模式要求主线程(IO处理单元)只负责监听文件描述符上是否有事件发生,有的话立即将该事件通知工作线程(逻辑单元),除此之外,主线程不做任何其他实质性的工作。读写数据、接受新连接、处理客户请求均在工作线程中完成。

使用同步IO模型(以epoll_wait函数为例)实现的Reactor模式的工作流程是:

  • 1.主线程往epoll内核事件表中注册socket上的读就绪事件。

  • 2.主线程调用epoll_wait等待socket上有数据可读。

  • 3.当socket上有数据可读,epoll_wait函数通知主线程,主线程将socket可读事件放入请求队列。

  • 4.睡眠在请求队列上的某个工作线程被唤醒,它从socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件。

  • 5.主线程调用epoll_wait等待socket可写。

  • 6.当socket可写时,epoll_wait函数通知主线程,主线程将socket可写事件放入请求队列。

  • 7.睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果。

在这里插入图片描述

8.4.2 Proactor模式

​ Proactor模式将所有IO操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑,因此,Proactor模式更符合图8-4所描述的服务器编程框架。

使用异步IO模型(如aio_read、aio_write函数)实现的Proactor模型的工作流程是:

  • 1.主线程调用aio_read向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例)。

  • 2.主线程等待读完成期间可继续处理其他逻辑。

  • 3.当socket上的数据被读入用户缓冲区后,内核向应用进程发送一个信号,以通知数据已可用。

  • 4.应用进程预先定义好的信号处理函数选择一个工作线程来处理客户请求,工作线程处理完客户请求后,调用aio_write向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用进程(仍以信号为例)。

  • 5.主线程继续处理其他逻辑。

  • 6.当用户缓冲区中的数据被写入socket后,内核向应用进程发送一个信号,以通知应用进程数据已经发送完毕。

  • 7.应用进程预先定义好的信号处理函数选择一个工作线程做善后处理,如是否关闭socket。

在这里插入图片描述

8.4.3 模拟Proactor模式

​ 使用同步IO方式模拟Proactor模式的一种方法:主线程执行数据读写操作,读写完成后,主线程向工作线程通知这一完成事件,从工作线程的角度来看,它们直接获得了数据读写的结果,接下来只需对读写的结果进行逻辑处理。

使用同步IO模型(以epoll_wait函数为例)模拟出的Proactor模式的工作流程如下:

  • 1.主线程往epoll内核事件表中注册socket上的读就绪事件。

  • 2.主线程调用epoll_wait等待socket上有数据可读。

  • 3.当socket上有数据可读时,epoll_wait函数通知主线程,主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。

  • 4.睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中注册socket上的写就绪事件。

  • 5.主线程调用epoll_wait等待socket可写。

  • 6.当socket可写时,epoll_wait函数通知主线程,主线程往socket上写入服务器处理客户请求的结果。

在这里插入图片描述

8.5 两种高效的并发模式

并发编程的目的是让进程“同时”执行多个任务,如果程序是计算密集型的,并发编程并没有优势,反而由于任务的切换使效率降低,但如果进程是IO密集型的,如经常读写文件、访问数据库等,并发编程就有优势了,由于IO操作的速度远没有CPU的计算速度快,所以让程序阻塞于IO操作将浪费大量CPU时间。

​ 并发模式指IO处理单元和多个逻辑单元之间协调完成任务的方法,服务器主要有两种并发编程模式:半同步/半异步(half-sync/half-async)和领导者/追随者(Leader/Follwers)模式。

8.5.1 半同步/半异步模式

​ 在IO模型中,同步和异步区分的是内核向应用进程通知的是何种IO事件(是就绪时间还是完成事件),以及该由谁来完成IO读写(是应用进程还是内核)。

​ 在并发模式中,同步指的是进程完全按照代码序列顺序执行异步指的是程序的执行需要由系统事件来驱动,常见的系统事件包括中断、信号。

在这里插入图片描述

​ 按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。

​ 异步线程的执行效率高,实时性强,这是很多嵌入式程序采用的模型,但编写以异步方式执行的程序相对复杂,难以调试和扩展,且不适合大量的并发(大量信号和跳转)。而同步线程虽然执行效率相对较低,实时性较差(阻塞中,其他IO被搁置),但逻辑简单。

​ 对于像服务器这种既要求较好的实时性,又要求能同时处理多个客户请求的应用,就应该同时使用同步线程和异步线程来实现,即半同步/半异步模式。

​ 半同步/半异步模式中,同步线程用于处理客户逻辑,相当于逻辑单元,异步线程用于处理IO时间,相当于IO处理单元。异步线程监听到客户请求后,就将其封装成请求对象插入请求队列,请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。具体选择哪个工作线程来为新的客户请求服务,取决于请求队列的设计,如最简单的轮流选取工作线程的Round Robin算法,也可通过条件变量或信号量来随机选择一个工作线程。

在这里插入图片描述

​ 如果考虑两种事件处理模式(reactor和proactor)和几种IO模型,则半同步/半异步模式就存在多种变体,其中一种变体被称为半同步/半反应堆(half-sync/half-reactive)模式

在这里插入图片描述

​ 上图中,异步线程只有主线程,它负责监听所有socket上的事件,如果监听socket上有可读事件发生,即有新的连接请求到来,主线程就接受它以得到新的连接socket,然后往epoll内核事件表中注册新的已连接socket上的读写事件。如果连接socket上有读写事件发生,即有新的客户请求到来或有数据要发送到客户端,主线程就将该连接socket插入请求队列中。所有工作线程都睡眠在请求队列上,有任务到来时,它们通过竞争(如申请互斥锁)获得任务的接管权,这种竞争机制使得只有空闲的工作线程才有机会处理新任务。

​ 上图中,主线程插入请求队列中的任务是就绪的连接socket,说明采用的事件处理模式是Reactor模式(它要求工作线程自己从socket上读取客户请求和往socket写入服务器应答),这就是该模式的名称中half-reactive的含义。

​ 实际上,半同步/半反应堆模式也能使用模拟的Proactor事件处理模式,即由主线程完成数据的读写,此时,主线程一般会将应用数据、任务类型等信息封装为一个任务对象,然后将其(或指向该任务对象的一个指针)插入请求队列,工作线程从请求队列中取得任务对象后,即可处理它,而无须执行读写操作了。

​ 半同步/半反应堆模式存在如下缺点

  • 1.主线程和工作线程共享请求队列,主线程往请求队列中添加任务,或工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费CPU时间。
  • 2.每个工作线程同一时间只能处理一个客户请求,如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务,客户端的响应速度将变慢,如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量CPU时间。

​ 下图描述了一种相对高效的半同步/半异步模式,它的每个工作线程都能同时处理多个客户连接:

在这里插入图片描述

​ 上图中,主线程只管理监听socket,连接socket由工作线程来管理,当有新连接到来时,主线程就接受之并将新返回的连接socket派发给某个工作线程,此后该新socket上的任何IO操作都由被选中的工作线程来处理,直到客户关闭连接。主线程往工作线程派发socket的最简单方式,是往它和工作线程之间的管道里写数据,工作线程检测到管道上有数据可读时,就分析是否是一个新客户连接请求到来,如果是,就把新socket上的读、写事件注册到自己的epoll内核事件表中。

​ 可见,上图中每个线程(主线程和工作线程)都维持自己的事件循环,它们各自独立地监听不同的事件,因此,在这种高效的半同步/半异步模式中,每个线程都工作在异步模式,所以它并非严格意义上的半同步/半异步模式。

8.5.2 领导者/追随者模式

领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负责监听IO事件,而其他线程都是追随者,他们休眠在线程池中等待成为新领导者。当前的领导者如果检测到IO事件,首先从线程池中推选出新的领导者线程,然后处理IO事件,此时,新的领导者等待新的IO事件,而原来的领导者则处理IO事件,两者实现了并发。

​ 领导者/追随者模式包含以下几个组件:句柄集(HandleSet)、线程集(ThreadSet)、事件处理器(EventHandler)、具体的事件处理器(ConcreteEventHandler),它们的关系见下图:

在这里插入图片描述

  • 1.句柄集:句柄(Handle)用于表示IO资源,在Linux下通常是一个文件描述符。句柄集管理众多句柄,它使用wait_for_event方法来监听这些句柄上的IO事件,并将其中的就绪事件通知给领导者线程。领导者则调用绑定到Handle上的事件处理器来处理事件。领导者将Handle和事件处理器绑定是通过register_handle方法实现的。

  • 2.线程集:这个组件是所有工作线程(包括领导者线程和追随者线程)的管理者。它负责各线程之间的同步,以及新领导者线程的推选。线程集中的线程在任一时间必处于以下三种状态之一:

    • (1)Leader:线程当前处于领导者身份,负责等待句柄集上的IO事件。

    • (2)Processing:线程正在处理事件。领导者检测到IO事件之后,可以转移到Processing状态来处理该事件,并调用promote_new_leader方法推选新的领导者;也可以指定其他追随者来处理事件(Event Handoff),此时领导者的地位不变。当处于Processing状态的线程处理完事件后,如果当前线程中没有领导者,则它将成为新的领导者,否则它直接转变为追随者。

    • (3)Follower:线程当前处于追随者身份,通过调用线程集的join方法等待成为新领导者,也可能被当前的领导者指定来处理新任务。

    • 在这里插入图片描述

  • 3.事件处理器和具体的事件处理器:通常包含一个或多个回调函数handle_event,这些回调函数用于处理事件对应的业务逻辑。事件处理器在使用前需要被绑定到某个句柄上,当该句柄上有事件发生时,领导者就执行与之绑定的事件处理器中的回调函数。具体的事件处理器是事件处理器的派生类,它们必须重新实现基类的handle_event方法,以处理特定的任务。

  • 在这里插入图片描述

​ 由于领导者线程自己监听IO事件并处理客户请求,因此领导者/追随者模式不需要在线程之间传递任何额外数据,也无须像半同步/半反应堆模式那样在线程之间同步对请求队列的访问。但领导者/追随者的一个缺点是无法让每个工作线程像图8-11那样独立管理多个客户连接。

8.6 有限状态机

​ 接下来介绍一种逻辑单元内部的高效编程方法:有限状态机(finite state machine)。

​ 有的应用层协议头部包含数据包类型字段,每种类型可以映射为逻辑单元的一种执行状态,服务器可根据它来编写相应的处理逻辑:

STATE_MACHINE (Package _pack) {
    PackageType _type = _pack.GetType();
    switch (_type) {
        case type_A:
            process_package_A(_pack);
            break;
        case type_B:
            process_package_B(_pack);
            break;
    }
}

​ 上例代码就是一个简单的有限状态机,只不过该状态机的每个状态都是相互独立的,即状态之间没有相互转移。状态之间的转移需要状态机内部驱动:

STATE_MACHINE() {
    State cur_State = type_A;
    while (cur_State != type_C) {
        Package _pack = getNewPackage();
        switch (cur_State) {
            case type_A:
                process_package_state_A(_pack);
                cur_State = type_B;
                break;
            case type_B:
                process_package_state_B(_pack);
                cur_State = type_C;
                break;
        }
    }
}

​ 该状态机包含三种状态:type_A、type_B、type_C,其中type_A是状态机的开始状态,type_C是状态机的结束状态。状态机的当前状态记录在cur_State变量中。在一次循环中,状态机先通过getNewPackage方法获得一个新数据包,然后根据cur_State变量的值判断如何处理该数据包,数据包处理完后,状态机通过给cur_State变量传递目标状态值来实现状态转移,那么当状态机进入下一次循环时,它将执行新的状态对应的逻辑。

下面考虑有限状态机的一个实例:HTTP请求的读取和分析。

​ 很多网络协议,包括TCP和IP协议,都在首部中提供首部长度字段,程序根据该字段的值就可知道是否接收到一个完整的协议头部。但HTTP协议没有这样的头部长度字段,且其头部长度变化也很大,可以只有十几字节,也可以有上百字节。根据HTTP协议,我们判断HTTP头部结束的依据是遇到一个空行,该空行仅包含一对回车换行符()。如果一次读操作没有读入HTTP请求的整个头部,即没有遇到空行,那么我们必须等待客户继续写数据并再次读入。因此,我们每完成一次读操作,就要分析新读入的数据中是否有空行。但在寻找空行的过程中,我们可以同时完成对整个HTTP头的分析,以提高解析HTTP请求的效率。以下代码使用主、从两个有限状态机实现了最简单的HTTP请求的读取和分析:

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <libgen.h>
#define BUFFER_SIZE 4096
// 主状态机有两种状态,当前正在分析请求行(HTTP请求头部的第一行)、当前正在分析头部字段
enum CHECK_STATE {CHECK_STATE_REQUESTLINE = 0, CHECK_STATE_HEADER};
// 从状态机有三种状态,用来表示行的读取状态,分别表示读到一个完整行、行出错、行数据尚不完整
enum LINE_STATUS {LINE_OK = 0, LINE_BAD, LINE_OPEN};
// 服务器处理HTTP请求的结果:
// NO_REQUEST:请求不完整,需要继续读取客户数据
// GET_REQUEST:获得了一个完整的客户请求
// BAD_REQUEST:客户请求有语法错误
// FORBIDDEN_REQUEST:客户对资源没有足够的访问权限
// INTERNAL_ERROR:服务器内部错误
// CLOSED_CONNECTION:客户已经关闭连接
enum HTTP_CODE {NO_REQUEST, GET_REQUEST, BAD_REQUEST, FORBIDDEN_REQUEST, INTERNAL_ERROR,
                CLOSED_CONNECTION};
// 为简化问题,我们不给客户发送一个完整的HTTP应答报文,而是根据服务器的处理结果发送如下成功或失败信息
static const char *szret[] = {"I get a correct result\n", "Something wrong\n"};

// 解析出首部中一行的内容,返回值是从状态机的一个状态
LINE_STATUS parse_line(char *buffer, int &checked_index, int &read_index) {
    char temp;
    // checked_index指向buffer(用户的读缓冲区)中当前正在分析的字节,read_index指向buffer中客户数据的尾后字节
    // buffer中第0~checked_index字节都已分析完毕,第checked_index~read_index-1字节由以下循环逐个分析
    for (; checked_index < read_index; ++checked_index) {
        // 获得当前要分析的字节
        temp = buffer[checked_index];
        // 如果当前分析的字节是\r,即回车符,则说明可能读到了一个完整行
        if (temp == '\r') {
            // 如果\r字符是目前buffer中最后一个读到的客户数据,则此次分析没有读到一个完整行
            // 返回LINE_OPEN表示还要继续读取客户数据才能进一步分析
            if ((checked_index + 1) == read_index) {
                return LINE_OPEN;
            // 如果下一个字符是\n,说明我们成功读到一个完整的行
            } else if (buffer[checked_index + 1] == '\n') {
                buffer[checked_index++] = '\0';
                buffer[checked_index++] = '\0';
                return LINE_OK;
            }
            // 否则说明客户发送的HTTP请求存在语法问题(这意味着除了每行结尾,行内不能出现\r)
            return LINE_BAD;
        // 如果当前的字节是\n,即换行符,则说明可能读到一个完整的行
        } else if (temp == '\n') {
            // 如果有前一个字符,且前一个字符是\r,说明读到了一个完整的行
            if ((checked_index > 1) && buffer[checked_index - 1] == '\r') {
                buffer[checked_index - 1] = '\0';
                buffer[checked_index++] = '\0';
                return LINE_OK;
            }
            // 否则说明客户发送的HTTP请求存在语法问题(这意味着除了每行结尾,行内不能出现\n)
            return LINE_BAD;
        }
    }
    // 如果没有遇到\r或\n,则返回LINE_OPEN,表示还需继续读取客户数据才能进一步分析
    return LINE_OPEN;
}

// 分析请求行(HTTP头部中的第一行)
// 一个请求行的例子:GET http://www.a.com/index.html HTTP/1.0
HTTP_CODE parse_requestline(char *temp, CHECK_STATE &checkstate) {
    // strpbrk函数在一个字符串中查找第一个匹配指定字符集合中任何字符的位置
    char *url = strpbrk(temp, " \t");
    // 如果请求行中没有\t或空格,则HTTP请求有语法错误
    if (!url) {
        return BAD_REQUEST;
    }
    // 此处将上例中,GET后的空格或\t改为了\0,并将url指向了http::的h
    *url++ = '\0';

    char *method = temp;
    // 如果不是GET请求,当成语法错误处理
    if (strcasecmp(method, "GET") == 0) {
        printf("The request method is GET\n");
    } else {
        return BAD_REQUEST;
    }

    // strspn函数用于计算一个字符串中连续包含在另一个字符串中的字符的长度
    // 此处是为了跳过上例中的GET和http::之间的多个空格或\t
    url += strspn(url, " \t");
    char *version = strpbrk(url, " \t");
    if (!version) {
        return BAD_REQUEST;
    }
    // 此处将上例中url和HTTP版本之间的空格或\t换成\0
    *version++ = '\0';
    // 防止url和HTTP版本之间有多个空格或\t
    version += strspn(version, " \t");
    // 只处理HTTP/1.1版本
    if (strcasecmp(version, "HTTP/1.1") != 0) {
        return BAD_REQUEST;
    }
    // 检查url是否以http://开头
    if (strncasecmp(url, "http://", 7) == 0) {
        // 将url指向上例中的www的第一个w
        url += 7;
        // strchr函数用于在一个字符串中查找指定字符的第一次出现的位置,并返回该位置的指针
        // 这里将url指向上例中的www.a.com/index.html中的/
        url = strchr(url, '/');
    }

    if (!url || url[0] != '/') {
        return BAD_REQUEST;
    }
    printf("The request URL is: %s\n", url);
    // 读完请求行,将从状态机改为CHECK_STATE_HEADER,表示接下来分析头部字段
    checkstate = CHECK_STATE_HEADER;
    // 继续读更多数据
    return NO_REQUEST;
}

// 分析头部字段
HTTP_CODE parse_headers(char *temp) {
    // 遇到一个空行,说明我们得到了正确的HTTP请求,返回GET_REQUEST表示我们获得了一个完整的客户请求
    // 在读取一行数据时,我们把该行中所有\r\n都改成了\0
    if (temp[0] == '\0') {
        return GET_REQUEST;
    // 处理HOST头部字段
    }  else if (strncasecmp(temp, "Host:", 5) == 0) {
        temp += 5;
        // 跳过冒号后的一个或多个空格或\t
        temp += strspn(temp, " \t");
        printf("the request host is: %s\n", temp);
    // 其他头部字段不处理
    } else {
        printf("I can not handle this header\n");
    }
    // 没有遇到空行,返回NO_REQUEST表示请求不完整,需要继续读取客户数据
    return NO_REQUEST;
}

// 分析HTTP请求的入口函数
HTTP_CODE parse_content(char *buffer, int &checked_index, CHECK_STATE &checkstate, 
                        int &read_index, int &start_line) {
    LINE_STATUS linestatus = LINE_OK;
    HTTP_CODE retcode = NO_REQUEST;
    // 如果读到了一整行
    while ((linestatus = parse_line(buffer, checked_index, read_index)) == LINE_OK) {
        // start_line是当前行的起始位置
        char *temp = buffer + start_line;
        // 更新下一行的起始位置
        start_line = checked_index;
        // checkstate是主状态机当前的状态
        switch (checkstate) {
            // 分析请求行
            case CHECK_STATE_REQUESTLINE:
            {
                retcode = parse_requestline(temp, checkstate);
                // 语法错误
                if (retcode == BAD_REQUEST) {
                    return BAD_REQUEST;
                }
                break;
            }
            // 分析头部字段
            case CHECK_STATE_HEADER:
            {
                retcode = parse_headers(temp);
                // 语法错误
                if (retcode == BAD_REQUEST) {
                    return BAD_REQUEST;
                // 已读到完整HTTP请求
                } else if (retcode == GET_REQUEST) {
                    return GET_REQUEST;
                }
                break;
            }
            default: 
            {
                return INTERNAL_ERROR;
            }
        }
    }
    // 如果没有读到完整行,表示还需继续读取客户数据才能进一步分析
    if (linestatus == LINE_OPEN) {
        return NO_REQUEST;
    // 既没有读到完整行,也没有返回继续读取数据,说明返回了LINE_BAD(语法错误)
    } else {
        return BAD_REQUEST;
    }
}

int main(int argc, char *argv[]) {
    if (argc < 2) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);
    int ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);
    ret = listen(listenfd, 5);
    assert(ret != -1);
    struct sockaddr_in client_address;
    socklen_t client_addrlength = sizeof(client_address);
    int fd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
    if (fd < 0) {
        printf("errno is: %s\n", errno);
    } else {
        char buffer[BUFFER_SIZE];
        memset(buffer, '\0', BUFFER_SIZE);
        int data_read = 0;
        int read_index = 0;    // 当前已读了多少客户数据
        int checked_index = 0;    // 当前已经分析完了多少字节的客户数据
        int start_line = 0;    // 行在buffer中的起始位置
        // 将主状态机状态设置为读请求行
        CHECK_STATE checkstate = CHECK_STATE_REQUESTLINE;
        while (1) {    // 循环读取客户数据并分析
            data_read = recv(fd, buffer + read_index, BUFFER_SIZE - read_index, 0);
            if (data_read == -1) {
                printf("reading failed\n");
                break;
            } else if (data_read == 0) {
                printf("remote client has closed the connection\n");
                break;
            }
            read_index += data_read;
            // 分析当前读取的数据
            HTTP_CODE result = parse_content(buffer, checked_index, checkstate, read_index, 
                                             start_line);
            // 尚未得到完整的HTTP请求,接着读
            if (result == NO_REQUEST) {
                continue;
            // 得到了一个完整的、正确的HTTP请求
            } else if (result == GET_REQUEST) {
                send(fd, szret[0], strlen(szret[0]), 0);
                break;
            // 其他情况表示发生错误
            } else {
                send(fd, szret[1], strlen(szret[1]), 0);
                break;
            }
        }
        close(fd);
    }
    close(listenfd);
    return;
}

​ 以上代码中有两个有限状态机,分别称其为主状态机和从状态机,主状态机在内部调用从状态机。先分析从状态机,即parse_line函数,它从buffer中解析出一个行,下图是它可能的状态和状态转移过程:

在这里插入图片描述

​ 从状态机的初始状态是LINE_OK,其原始驱动力来自于buffer中新到达的客户数据。在main函数中,我们循环调用recv函数往buffer中读入客户数据,每次成功读取数据后,就调用parse_content分析新读入的数据,parse_content函数首先要做的就是调用parse_line来获取一个行,假设服务器经过一次recv调用后,buffer的内容和部分变量的值如图8-16a所示:

在这里插入图片描述

​ parse_line函数处理后的结果如图8-16b所示,它挨个检查图8-16a所示的buffer中checked_index到read_index-1之间的字节,判断是否存在行结束符,并更新checked_index的值,当前buffer中不存在行结束符,因此parse_line函数返回LINE_OPEN。接下来,程序继续调用recv读取更多客户数据,这次读操作后buffer中的内容和部分变量的值如图8-16c所示,然后parse_line函数处理这部分新到的数据,如图8-16d所示,这次它读到了一个完整的行,即HOST: localhost\r\n,此时,parse_line函数就可以将这行内容递交给parse_content函数中的主状态机来处理了。

8.7 提高服务器性能的其他建议

​ 由于硬件技术飞速发展,现代服务器都不缺乏硬件资源,因此,我们需要考虑如何从软环境来提升服务器性能,服务器软环境一方面指系统的软件资源,如操作系统允许用户打开的最大文件描述符数量,另一方面指服务器程序本身,即如何从编程角度确保服务器的性能。

8.7.1 池

池的概念就是这样,池是一组资源的集合,这组资源在服务器启动的时候就被完全创建好并初始化,这称为静态资源分配。

​ 当服务器进入正式运行阶段,即开始处理客户请求的时候,如果它需要相关资源,就可以直接从池中获取,无须动态分配。显然,直接从池中获取所需资源比动态分配资源的速度要快得多,因为分配系统资源的系统调用都是很耗时的。当服务器处理完一个客户连接后,可以把相关资源放回池中,无须执行系统调用来释放资源。从最终效果看,池相当于服务器管理系统资源的应用层设施,它避免了服务器对内核的频繁访问。

​ 但既然池中的资源是预先静态分配的,我们无法预期应分配多少资源,这个问题的解决办法就是分配足够多的资源,即针对每个可能的客户连接都分配必要的资源,这通常会导致资源的浪费,因为任一时刻的客户数量都可能远远没有达到服务器能支持的最大客户数量,好在这种资源的浪费对服务器来说一般不会构成问题。还有一种解决方案是预先分配一定的资源,此后如果发现资源不够用,就再动态分配一些并加入池中。

​ 根据不同的资源类型,池可分为多种,常见的有内存池、进程池、线程池、连接池。

内存池通常用于socket的接收缓存和发送缓存,对于某些长度有限的客户请求,如HTTP请求,预先分配一个大小足够的接收缓存区是合理的。当客户请求超过接收缓冲区大小时,我们可以选择丢弃请求或动态扩大接收缓冲区

​ 进程池和线程池在并发编程中很常用,当我们需要一个工作进程或工作线程来处理新到来的客户请求时,我们可以直接从进程池或线程池中取得一个执行实体,而无须动态调用fork或pthread_create等函数来创建进程或线程

​ 连接池通常用于服务器或服务器机群的内部永久连接,图8-4中,每个逻辑单元可能都需要频繁访问本地的某个数据库,简单做法是,逻辑单元每次需要访问数据库时,就向数据库进程发起连接,而访问完后释放连接,显然这样做效率太低,一种解决方案是使用连接池,连接池是服务器预先和数据库进程建立的一组连接的集合,当某个逻辑单元需要访问数据库时,它可以从连接池中取得一个连接的实体并使用之,待完成数据库的访问后,逻辑单元再将该连接返还给连接池

8.7.2 数据复制

​ 高性能服务器应避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间的时候。如果内核可以直接处理从socket或文件读入的数据,则应用进程就没必要将这些数据从内核缓冲区复制到应用缓冲区中。

​ 此处的直接处理指的是应用进程不关心这些数据的内容,不需要对它们做任何分析,如ftp服务器,当客户请求一个文件时,服务器只需检测目标文件是否存在,以及客户是否有读取它的权限,而不会关心文件的具体内容,因此ftp服务器就无须把目标文件的内容完整读入应用进程缓冲区中,并调用send来发送,而是可以使用零拷贝函数sendfile来直接将其发送给客户端。

​ 此外,用户代码内部(不访问内核)的数据复制也应避免,例如,当两个工作进程之间要传递大量数据时,我们应考虑使用共享内存在它们之间直接共享这些数据,而不是使用管道或消息队列来传递,又比如我们可用指针来指出数据的位置,以便随后对该位置的内容进行访问,而不是把内容复制到另一个缓冲区中来使用,这样既浪费空间,又效率低下。

8.7.3 上下文切换和锁

​ 并发程序必须考虑上下文切换(context switch)问题,即进程切换或线程切换导致的系统开销。不应使用过多工作进程(线程),否则进程(线程)间的切换将占用大量CPU时间,服务器真正用于处理业务逻辑的CPU时间的比重就会变小。

​ 并发编程需要考虑的另一个问题是共享资源的加锁保护,锁通常被认为是导致服务器效率低下的一个因素,因为由锁引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源,因此,如果服务器有更好的解决方案,就应该避免使用锁。显然图8-11所描述的半同步/半异步模式就比图8-10所描述的半同步/半反应堆模式的效率高。如果服务器必须使用锁,则可以考虑减小锁的粒度,如使用读写锁,当所有工作线程只读取一块共享内存的内容时,读写锁并不会增加系统的额外开销,只有当一个工作线程需要写这块内存时,系统才必须去锁住这块区域。

第9章 IO复用

​ IO复用使程序能同时监听多个文件描述符,这可以提高程序的性能,通常网络程序在以下情况需要使用IO复用:

  • 1.客户端进程需要同时处理多个socket。

  • 2.客户端进程需要同时处理用户输入和网络连接。

  • 3.TCP服务器要同时处理监听socket和连接socket。

  • 4.服务器要同时处理TCP请求和UDP请求。

  • 5.服务器要同时监听多个端口,或处理多种服务,如xinetd服务器。

IO复用能同时监听多个文件描述符,但它本身是阻塞的,且当多个文件描述符同时就绪时,如果不采取额外措施,进程只能按顺序依次处理其中的每个文件描述符,这使得服务器看起来像是串行工作的,如果要实现并发,只能用多进程或多线程等编程手段。

​ Linux下实现IO复用的系统调用主要有select、poll、epoll。

9.1 select系统调用

​ select系统调用的用途是:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写、异常事件

9.1.1 select API

​ select 系统调用的原型如下:

#include <sys/select.h>

// nfds 指定被监听的文件描述符的总数,通常是监听的所有文件描述符中的最大值加1
// readfds, writefds, exceptfds 分别指向可读、可写和异常等事件对应的文件描述符集合
// timeout 设置 select 函数的超时时间,0 立即返回,NULL 一直阻塞
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);

// fd_set 结构体仅包含一个整形数组,该数组的每个元素的每一位 bit 标记一个文件描述符,容纳数量由 FD_SETSIZE 指定
FD_ZERO(fd_set* fdset);			// 清除 fdset 的所有位
FD_SET(int fd, fd_set *fdset);	// 设置 fdset 的位 fd
FD_CLR(int fd, fd_set *fdset);	// 清除 fdset 的位 fd
int FD_ISSET(int fd, fd_set *fdset); // 测试 fdset 的位 fd 是否被设置

struct timeval
{
    long tv_sec;	// 秒数
    long tv_usec;	// 微秒数
}
9.1.2 文件描述符就绪条件

​ 在网络编程中,以下情况认为socket可读

  • 1.socket内核接收缓存区中的字节数大于其低水位标记SO_RCVLOWAT,此时我们可以无阻塞地读该socket,且读操作返回的字节数大于0。

  • 2.socket通信的对方关闭连接,此时对该socket的读操作将返回0。

  • 3.监听socket上有新的连接请求。

  • 4.socket上有未处理的错误,此时我们可用getsockopt函数来读取和清除该错误。

以下情况认为socket可写

  • 1.socket内核发送缓存区中的可用字节数大于或等于其低水位标记SO_SNDLOWAT,此时我们可以无阻塞地写该socket,且写操作返回的字节数大于0。

  • 2.socket的写操作被关闭,对写操作被关闭的socket执行写操作将触发SIGPIPE信号。

  • 3.socket使用非阻塞connect连接成功或失败(超时)后。

  • 4.socket上有未处理的错误,此时我们可用getsockopt函数来读取和清除该错误。

select函数能处理的异常情况只有一种,socket上接收到带外数据

9.1.3 处理带外数据

​ socket上接收到普通数据和带外数据都将使select函数返回,以下代码同时处理这两者:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <libgen.h>

int main(int argc, char *argv[]) {
    if (argc <= 2) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);
    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);
    ret = listen(listenfd, 5);
    assert(ret != -1);

    struct sockaddr_in client_address;
    socklen_t client_addrlength = sizeof(client_address);
    int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
    if (connfd < 0) {
        printf("errno is: %d\n", errno);
        close(listenfd);
    }

    char buf[1024];
    fd_set read_fds;
    fd_set exception_fds;
    FD_ZERO(&read_fds);
    FD_ZERO(&exception_fds);

    while (1) {
        memset(buf, '\0', sizeof(buf));
        FD_SET(connfd, &read_fds);
        FD_SET(connfd, &exception_fds);
        ret = select(connfd + 1, &read_fds, NULL, &exception_fds, NULL);
        if (ret < 0) {
            printf("selection failure\n");
            break;
        }

        if (FD_ISSET(connfd, &read_fds)) {
            ret = recv(connfd, buf, sizeof(buf) - 1, 0);
            if (ret <= 0) {
                break;
            }
            printf("get %d bytes of normal data: %s\n", ret, buf);
        } else if (FD_ISSET(connfd, &exception_fds)) {
            ret = recv(connfd, buf, sizeof(buf) - 1, MSG_OOB);
            if (ret <= 0) {
                break;
            }
            printf("get %d bytes of oob data: %s\n", ret, buf);
        }
    }
    close(connfd);
    close(listenfd);
    return 0;
}

9.2 poll系统调用

​ poll系统调用和select系统调用类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者.

#include <poll.h>

// fds 参数指定感兴趣的文件描述符上发生的可读、可写和异常事件
// nfds 参数指定被监听事件集合 fds 的大小,实际类型为 unsigned long int
// timeout 指定 poll 的超时值,单位是毫秒,-1 永远阻塞,0 直接返回
// [return] 和 select 一样
int poll(struct pollfd* fds, nfds_t nfds, int timeout);

// pollfd 结构体
struct pollfd
{
    int fd;	// 文件描述符
    short events; // 注册的事件,一系列 POLL 事件的按位或
    short revents; // 实际发生的事件,内核填充
}

9.3 epoll系列系统调用

9.3.1 内核事件表

​ epoll函数是Linux特有的IO复用函数,它在实现和使用上与select、poll函数有很大差异。

​ 首先,epoll函数使用一组函数来完成任务,而非单个函数,其次,epoll函数把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll函数那样每次调用都要重复传入文件描述符集或事件集。epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表,这个文件描述符使用epoll_create函数来创建。

#include <sys/epoll.h>

// 创建标识内核中的事件表,size 参数并无实际作用
// [return] 返回的 fd 将作为其他所有 epoll 系统调用的第一个参数
int epoll_create(int size);

// 操作内核事件表
// op 参数指定操作类型,由 EPOLL_CTL_ADD|EPOLL_CTL_MOD|EPOLL_CTL_DEL 组成
// fd 参数是要操作的文件描述符
// event 参数指定事件
// [return] 成功 0,失败 -1 并设置 errno
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

struct epoll_event {
    _uint32_t events; // epoll 事件,和 poll 类型基本一致
    epoll_data_t data; // 用户数据
}

// 联合体,不能同时使用其ptr成员和fd成员
typedef union epoll_data {
    void* ptr; // 指向用户定义数据的指针
    int fd; //指定要监视的文件描述符
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;
9.3.2 epoll_wait函数

​ epoll_wait,它在一段超时时间内等待一组文件描述符上的事件

// timeout 指定超时,
// maxevents 指定最多监听多少个事件
// epoll_wait函数如果检测到事件,就将所有就绪的事件从内核事件表(由epfd参数指定的)中复制到它的第二个参数events指向的数组中,这个数组只用于输出epoll_wait函数检测到的就绪事件,而不像select和poll函数的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件,这样就极大地提高了应用进程索引就绪文件描述符的效率
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
9.3.3 LT和ET模式

​ epoll对文件描述符的操作有两种模式:LT(Level Trigger,电平触发)模式和ET(Edge Trigger,边沿触发)模式。

  • ​ LT:Level Trigger,默认的,epoll_wait 检测到其上有事件发生并将此事件通知应用程序之后,应用程序可以不立即处理该事件,下次调用 epoll_wait 还可以再次向应用程序通告此事件
  • ​ ET:Edge Trigger,epoll_wait 检测到就绪事件之后必须处理,效率比 LT 模式要高,需要指定 EPOLLET 事件类型

​ ET模式降低了同一个epoll事件被重复触发的次数,因此效率比LT模式高。

使用ET模式的文件描述符应该是非阻塞的,如果文件描述符是阻塞的,那么读或写操作将会因为没有后续事件而一直处于阻塞状态。

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>
#include <libgen.h>

#define MAX_EVENT_NUMBER 1024
#define BUFFER_SIZE 10

// 将文件描述符设为非阻塞的
int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}

// 将文件描述符fd参数上的EPOLLIN注册到epollfd参数指示的内核事件表中
// 参数enable_et指定是否对fd参数启用ET模式
void addfd(int epollfd, int fd, bool enable_et) {
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN;
    if (enable_et) {
        event.events |= EPOLLET;
    }
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
    setnonblocking(fd);
}

// LT模式的工作流程
void lt(epoll_event *events, int number, int epollfd, int listenfd) {
    char buf[BUFFER_SIZE];
    for (int i = 0; i < number; ++i) {
        int sockfd = events[i].data.fd;
        if (sockfd == listenfd) {
            struct sockaddr_in client_address;
            socklen_t client_addrlength = sizeof(client_address);
            int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
            addfd(epollfd, connfd, false);
        } else if (events[i].events & EPOLLIN) {
            // 只要socket读缓存中还有未读出的数据,这段代码就被触发
            printf("event trigger once\n");
            memset(buf, '\0', BUFFER_SIZE);
            int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
            if (ret <= 0) {
                close(sockfd);
                continue;
            }
            printf("get %d bytes of content: %s\n", ret, buf);
        } else {
            printf("something else happened\n");
        }
    }
}

// ET模式的工作流程
void et(epoll_event *events, int number, int epollfd, int listenfd) {
    char buf[BUFFER_SIZE];
    for (int i = 0; i < number; ++i) {
        int sockfd = events[i].data.fd;
        if (sockfd == listenfd) {
            struct sockaddr_in client_address;
            socklen_t client_addrlength = sizeof(client_address);
            int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
            addfd(epollfd, connfd, true);
        } else if (events[i].events & EPOLLIN) {
            // 这段代码不会被重复触发,所以需要循环读取数据
            printf("event trigger once\n");
            while (1) {
                memset(buf, '\0', BUFFER_SIZE);
                int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
                if (ret < 0) {
                    if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) {
                        printf("read later\n");
                        break;
                    }
                    close(sockfd);
                    break;
                } else if (ret == 0) {
                    close(sockfd);
                } else {
                    printf("get %d bytes of content: %s\n", ret, buf);
                }
            }
        } else {
            printf("something else happened\n");
        }
    }
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);

    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(listenfd, 5);
    assert(ret != -1);

    epoll_event events[MAX_EVENT_NUMBER];
    int epollfd = epoll_create(5);
    assert(epollfd != -1);
    addfd(epollfd, listenfd, true);

    while (1) {
        int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
        if (ret < 0) {
            printf("epoll failure\n");
            break;
        }

        // 使用LT模式
        lt(events, ret, epollfd, listenfd);
        // 使用ET模式
        // et(events, ret, epollfd, listenfd);
    }

    close(listenfd);
    return 0;
}
9.3.4 EPOLLONESHOT事件

​ 即使我们使用ET模式,一个socket上的某个事件还是可能被触发多次,这在并发程序中会引起问题,比如一个线程(或进程,下同)在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中该socket上又有新数据可读(EPOLLIN再次被触发),此时另一个线程被唤醒来读取这些新数据,于是就出现了两个线程同时操作一个socket的局面,这不是我们所期望的,我们期望的是一个socket连接在任一时刻都只被一个线程处理,这可用EPOLLONESHOT事件实现。

对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写、异常事件,且只触发一次,除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件,这样,当一个线程在处理某个socket时,其他线程不可能有机会操作socket。注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下次可读时,其EPOLLIN事件能触发,从而让其他线程有机会处理这个socket。

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>

#define MAX_EVENT_NUMBER 1024
#define BUFFER_SIZE 1024

struct fds {
    int epollfd;
    int sockfd;
};

int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}

// 将fd参数上的EPOLLIN和EPOLLET事件注册到epollfd参数指示的内核事件表中
// 参数oneshot指定是否注册fd参数上的EPOLLONESHOT事件
void addfd(int epollfd, int fd, bool oneshot) {
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET;
    if (oneshot) {
        event.events |= EPOLLONESHOT;
    }
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
    setnonblocking(fd);
}

// 重置fd参数上的事件,这样操作后,可以再次触发fd参数上的事件
void reset_oneshot(int epollfd, int fd) {
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
    epoll_ctl(epollfd, EPOLL_CTL_MOD, fd, &event);
}

// 工作线程
void *worker(void *arg) {
    int sockfd = ((fds *)arg)->sockfd;
    int epollfd = ((fds *)arg)->epollfd;
    printf("start new thread to receive data on fd: %d\n", sockfd);
    char buf[BUFFER_SIZE];
    memset(buf, '\0', BUFFER_SIZE);
    // 循环读取sockfd上的数据,直到遇到EAGAIN错误
    while (1) {
        int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
        if (ret == 0) {
            close(sockfd);
            printf("foreiner closed the connection\n");
            break;
        } else if (ret < 0) {
            if (errno == EAGAIN) {
                reset_oneshot(epollfd, sockfd);
                printf("read later\n");
                break;
            }
        } else {
            printf("get content: %s\n", buf);
            // 休眠5s,模拟数据处理过程
            sleep(5);
        }
    }
    printf("end thread receiving data on fd: %d\n", sockfd);
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);

    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(listenfd, 5);
    assert(ret != -1);

    epoll_event events[MAX_EVENT_NUMBER];
    int epollfd = epoll_create(5);
    assert(epollfd != -1);
    // 监听socket上不能注册EPOLLONESHOT事件,否则只能处理一个客户连接
    // 后续的连接请求将不再触发listenfd上的EPOLLIN事件
    addfd(epollfd, listenfd, false);

    while (1) {
        int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
        if (ret < 0) {
            printf("epoll failure\n");
            break;
        }

        for (int i = 0; i < ret; ++i) {
            int sockfd = events[i].data.fd;
            if (sockfd == listenfd) {
                struct sockaddr_in client_address;
                socklen_t client_addrlength = sizeof(client_address);
                int connfd = accept(listenfd, (struct sockaddr *)&client_address,  &client_addrlength);
                // 对每个非监听文件描述符都注册EPOLLONESHOT事件
                addfd(epollfd, connfd, true);
            } else if (events[i].events & EPOLLIN) {
                pthread_t thread;
                fds fds_for_new_worker;
                fds_for_new_worker.epollfd = epollfd;
                fds_for_new_worker.sockfd = sockfd;
                // 对每个客户请求都启动一个工作线程为其服务
                pthread_create(&thread, NULL, worker, (void *)&fds_for_new_worker);
            } else {
                printf("something else happened\n");
            }
        }
    }

    close(listenfd);
    return 0;
}

​ 从工作线程函数worker来看,如果一个工作线程处理完某个socket上的一次请求(我们用休眠5秒来模拟此过程)之后,又接收到该socket上新的客户请求,则该线程将继续为这个socket服务,并且由于该socket上注册了EPOLLONESHOT事件,主线程中epoll_wait函数不会返回该描述符的可读事件,从而不会有其他线程读这个socket,如果工作线程等待5秒后仍没收到该socket上的下一批客户数据,则它将放弃为该socket服务,同时调用reset_oneshot来重置该socket上的注册事件,这将使epoll有机会再次检测到该socket上的EPOLLIN事件,进而使得其他线程有机会为该socket服务。

​ 有了EPOLLONESHOT,尽管一个socket在不同时间可能被不同的线程处理,但同一时刻肯定只有一个线程在为它服务,这就保证了连接的完整性,从而避免了很多可能的竞态条件。

9.4 三组I/O复用函数的比较

在这里插入图片描述

9.5 I/O 复用的高级应用一:非阻塞 connect

​ connect 出错时有一个 errno 值:EINPROGRESS,这种错误发生在非阻塞的 sockct 调用 connect,而连接又没有立即建立时。根据 man 文档的解释,在这种情况下,我们可以调用 select、poll 等函数来监听这个连接失败的 socket 上的可写事件。当select、poll 等函数返回后,再利用 getsockopt 来读取错误码并清除该 socket 上的错误。如果错误码是0,表示连接成功建立,否则连接失败。

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <assert.h>
#include <stdio.h>
#include <time.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <string.h>

#define BUFFER_SIZE 1023

int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}

// 执行非阻塞connect,ip参数是ip地址,port参数是端口号,time参数是超时时间(毫秒)
// 函数成功时返回处于连接状态的socket,失败时返回-1
int unblock_connect(const char *ip, int port, int time) {
    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int sockfd = socket(PF_INET, SOCK_STREAM, 0);
    int fdopt = setnonblocking(sockfd);
    ret = connect(sockfd, (struct sockaddr *)&address, sizeof(address));
    if (ret == 0) {
        // 如果连接成功,恢复sockfd的属性,并立即返回sockfd
        printf("connect with server immediately\n");
        fcntl(sockfd, F_SETFL, fdopt);
        return sockfd;
    } else if (errno != EINPROGRESS) {
        // 如果连接没有建立,只有当errno是EINPROGRESS才表示连接正在进行,否则出错返回
        printf("unblock connect not support\n");
        return -1;
    }

    fd_set readfds;
    fd_set writefds;
    struct timeval timeout;

    FD_ZERO(&readfds);
    FD_SET(sockfd, &writefds);

    timeout.tv_sec = time;
    timeout.tv_usec = 0;

    ret = select(sockfd + 1, NULL, &writefds, NULL, &timeout);
    if (ret <= 0) {
        // select函数超时或出错,立即返回
        printf("connection time out\n");
        close(sockfd);
        return -1;
    }

    if (!FD_ISSET(sockfd, &writefds)) {
        printf("no events on sockfd found\n");
        close(sockfd);
        return -1;
    }

    int error = 0;
    socklen_t length = sizeof(error);
    // 调用getsockopt来获取并清除sockfd上的错误
    if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &length) < 0) {
        printf("get socket option failed\n");
        close(sockfd);
        return -1;
    }
    // 错误号不为0表示连接出错
    if (error != 0) {
        printf("connection failed after select with the error: %d\n", error);
        close(sockfd);
        return -1;
    }
    // 连接成功
    printf("connection ready after select with the socket: %d\n", sockfd);
    fcntl(sockfd, F_SETFL, fdopt);
    return sockfd;
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    int sockfd = unblock_connect(ip, port, 10);
    if (sockfd < 0) {
        return 1;
    }
    close(sockfd);
    return 0;
}

​ 但以上方法存在移植性问题,对于出错的socket,getsockopt函数在有些系统上(如Linux上)返回-1,而在有些系统上(如伯克利的UNIX)返回0。

9.6 I/O 复用的高级应用二:聊天室程序

客户端程序有两个功能:

  • 从标准输入终端读入用户数据,并将用户数据发送至服务器
  • 往标准输出终端打印服务器发送给它的数据

服务器的功能

  • 接收客户数据
  • 把客户数据发送给每一个登录到该服务器上的客户端(数据发送者除外)
9.6.1 客户端

​ 客户端程序使用poll函数同时监听用户输入和网络连接,并利用splice函数将用户输入内容直接定向到网络连接上发送,从而实现数据零拷贝,提高了程序执行效率,

// 启用GNU扩展,其中包含一些非标准的函数和特性
#define _GNU_SOURCE 1
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <poll.h>
#include <fcntl.h>

#define BUFFER_SIZE 64

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    struct sockaddr_in server_address;
    bzero(&server_address, sizeof(server_address));
    server_address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &server_address.sin_addr);
    server_address.sin_port = htons(port);

    int sockfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(sockfd >= 0);
    if (connect(sockfd, (struct sockaddr *)&server_address, sizeof(server_address)) < 0) {
        printf("connection failed\n");
        close(sockfd);
        return 1;
    }

    pollfd fds[2];
    // 注册文件描述符0(标准输入)和文件描述符sockfd上的可读事件
    fds[0].fd = 0;
    fds[0].events = POLLIN;
    fds[0].revents = 0;
    fds[1].fd = sockfd;
    fds[1].events = POLLIN | POLLRDHUP;
    fds[1].revents = 0;
    
    char read_buf[BUFFER_SIZE];
    int pipefd[2];
    int ret = pipe(pipefd);
    assert(ret != -1);

    while (1) {
        ret = poll(fds, 2, -1);
        if (ret < 0) {
            printf("poll failure\n");
            break;
        }

        if (fds[1].revents & POLLRDHUP) {
            printf("server close the connection\n");
            break;
        } else if (fds[1].revents & POLLIN) {
            memset(read_buf, '\0', BUFFER_SIZE);
            recv(fds[1].fd, read_buf, BUFFER_SIZE - 1, 0);
            printf("%s\n", read_buf);
        }

        if (fds[0].revents & POLLIN) {
            // 使用splice函数将用户输入的数据直接写到sockfd上(零拷贝)
            splice(0, NULL, pipefd[1], NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
            splice(pipefd[0], NULL, sockfd, NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
        }
    }

    close(sockfd);
    return 0;
}
9.6.2 服务器

​ 服务器使用poll函数同时管理监听socket和连接socket,且使用牺牲空间换取事件的策略来提高服务器性能

#define _GNU_SOURCE 1
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <poll.h>

// 最大用户数量
#define USER_LIMIT 5
// 读缓冲区的大小
#define BUFFER_SIZE 64
// 文件描述符数量限制
#define FD_LIMIT 65535

// 客户信息:客户socket地址、待写到客户端的数据的位置、从客户端已读入的数据
struct client_data {
    sockaddr_in address;
    char *write_buf;
    char buf[BUFFER_SIZE];
};

int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);

    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(listenfd, 5);
    assert(ret != -1);

    // 分配FD_LIMIT个client_data对象,我们直接把socket的值用作索引
    // 这样socket和客户数据的关联比较简单
    client_data *users = new client_data[FD_LIMIT];
    // 虽然我们分配了足够多的client_data对象,但为了提高poll函数性能,仍然有必要限制用户数量
    pollfd fds[USER_LIMIT + 1];
    int user_counter = 0;
    // 初始化客户数据对象
    for (int i = 1; i <= USER_LIMIT; ++i) {
        fds[i].fd = -1;
        fds[i].events = 0;
    }
    fds[0].fd = listenfd;
    fds[0].events = POLLIN | POLLERR;
    fds[0].revents = 0;

    while (1) {
        ret = poll(fds, user_counter + 1, -1);
        if (ret < 0) {
            printf("poll failure\n");
            break;
        }

        for (int i = 0; i < user_counter + 1; ++i) {
            if ((fds[i].fd == listenfd) && (fds[i].revents & POLLIN)) {
                struct sockaddr_in client_address;
                socklen_t client_addrlength = sizeof(client_address);
                int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
                if (connfd < 0) {
                    printf("errno is: %d\n", errno);
                    continue;
                }
                // 如果请求过多,则关闭新到的连接
                if (user_counter >= USER_LIMIT) {
                    const char *info = "too many users\n";
                    printf("%s", info);
                    send(connfd, info, strlen(info), 0);
                    close(connfd);
                    continue;
                }
                // fds和users数组中新增连接,users[connfd]就是新连接的客户信息
                ++user_counter;
                users[connfd].address = client_address;
                setnonblocking(connfd);
                fds[user_counter].fd = connfd;
                fds[user_counter].events = POLLIN | POLLRDHUP | POLLERR;
                fds[user_counter].revents = 0;
                printf("comes a new user, now have %d users\n", user_counter);
            } else if (fds[i].revents & POLLERR) {
                printf("get an error from %d\n", fds[i].fd);
                char errors[100];
                memset(errors, '\0', 100);
                socklen_t length = sizeof(errors);
                if (getsockopt(fds[i].fd, SOL_SOCKET, SO_ERROR, &errors, &length) < 0) {
                    printf("get socket option failed\n");
                }
                continue;
            } else if (fds[i].revents & POLLRDHUP) {
                // 如果客户端关闭连接,则服务器也关闭对应的连接,并将总用户数减1
                // 此处作者想把fds数组中,最后一个位置的元素放到此处正要关闭的位置
                // 但users数组的索引是fd,因此users数组不应做改变,此处应删除下一句代码
                users[fds[i].fd] = users[fds[user_counter].fd];    // delete this
                close(fds[i].fd);
                fds[i] = fds[user_counter];
                --i;
                --user_counter;
                printf("a client left\n");
            } else if (fds[i].revents & POLLIN) {
                int connfd = fds[i].fd;
                memset(users[connfd].buf, '\0', BUFFER_SIZE);
                ret = recv(connfd, users[connfd].buf, BUFFER_SIZE - 1, 0);
                printf("get %d bytes of client data %s from %d\n", ret, users[connfd].buf, connfd);
                if (ret < 0) {
                    // 如果读出错,就关闭连接
                    if (errno != EAGAIN) {
                        close(connfd);
                        // 此处关闭连接时,也不应移动users数组,因为users数组是按套接字索引的
                        users[fds[i].fd] = users[fds[user_counter].fd];    // delete this
                        fds[i] = fds[user_counter];
                        --i;
                        --user_counter;
                    }
                } else if (ret == 0) {
                
                } else {
                    // 如果接收到客户数据,则通知其他socket连接准备写数据
                    for (int j = 1; j <= user_counter; ++j) {
                        // 跳过发来消息的客户
                        if (fds[j].fd == connfd) {
                            continue;
                        }
                        
                        // 作者在干什么?可能是想关闭读,但关闭读应该是用&=
                        // 如果关闭读,说明套接字处于写状态时不能读,感觉没必要,可以同时检测读和写
                        fds[j].events |= ~POLLIN;
                        fds[j].events |= POLLOUT;
                        users[fds[j].fd].write_buf = users[connfd].buf;
                    }
                }
            } else if (fds[i].revents & POLLOUT) {
                int connfd = fds[i].fd;
                if (!users[connfd].write_buf) {
                    continue;
                }
                ret = send(connfd, users[connfd].write_buf, strlen(users[connfd].write_buf), 0);
                users[connfd].write_buf = NULL;
                // 写完数据后重新注册fds[i]上的可读事件,此处应使用&=
                fds[i].events |= ~POLLOUT;
                fds[i].events |= POLLIN;
            }
        }
    }

    delete[] users;
    close(listenfd);
    return 0;
}

9.7 I/O 复用的高级应用二:同时处理 TCP 和 UDP 服务

​ 从bind系统调用的参数来看,一个socket只能与一个socket地址绑定,即一个socket只能用来监听一个端口,因此,如果服务器要同时监听多个端口,就必须创建多个socket,并将它们分别绑定到各个端口上,这样,服务器就需要同时管理多个监听socket,这可使用IO复用技术实现。另外,即使是同一个端口,如果服务器要同时处理该端口上的TCP和UDP请求,也需要创建两个不同的socket,一个是流socket,另一个是数据报socket,并将它们都绑定到该端口上。

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>
#include <libgen.h>

#define MAX_EVENT_NUMBER 1024
#define TCP_BUFFER_SIZE 512
#define UDP_BUFFER_SIZE 1024

int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}

void addfd(int epollfd, int fd) {
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET;
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
    setnonblocking(fd);
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    // 创建TCP socket,并将其绑定在端口port上
    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);

    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(listenfd, 5);
    assert(ret != -1);

    // 创建UDP socket,并将其绑定到端口port上
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    int udpfd = socket(PF_INET, SOCK_DGRAM, 0);
    assert(udpfd >= 0);

    epoll_event events[MAX_EVENT_NUMBER];
    int epollfd = epoll_create(5);
    assert(epollfd != -1);
    // 注册TCP socket和UDP socket上的可读事件
    addfd(epollfd, listenfd);
    addfd(epollfd, udpfd);

    while (1) {
        int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
        if (number < 0) {
            printf("epoll failure\n");
            break;
        }

        for (int i = 0; i < number; ++i) {
            int sockfd = events[i].data.fd;
            if (sockfd == listenfd) {
                struct sockaddr_in client_address;
                socklen_t client_addrlength = sizeof(client_address);
                int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
                addfd(epollfd, connfd);
            } else if (sockfd == udpfd) {
                char buf[UDP_BUFFER_SIZE];
                memset(buf, '\0', UDP_BUFFER_SIZE);
                struct sockaddr_in client_address;
                socklen_t client_addrlength = sizeof(client_address);

                ret = recvfrom(udpfd, buf, UDP_BUFFER_SIZE - 1, 0, (struct sockaddr *)&client_address,
                               &client_addrlength);
                if (ret > 0) {
                    sendto(udpfd, buf, UDP_BUFFER_SIZE - 1, 0, (struct sockaddr *)&client_address,
                           client_addrlength);
                }
            } else if (events[i].events & EPOLLIN) {
                char buf[TCP_BUFFER_SIZE];
                while (1) {
                    memset(buf, '\0', TCP_BUFFER_SIZE);
                    ret = recv(sockfd, buf, TCP_BUFFER_SIZE - 1, 0);
                    if (ret < 0) {
                        if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) {
                            break;
                        }
                        close(sockfd);
                        break;
                    } else if (ret == 0) {
                        close(sockfd);
                    } else {
                        send(sockfd, buf, ret, 0);
                    }
                }
            } else {
                printf("something else happened\n");
            }
        }
    }

    close(listenfd);
    return 0;
}

9.8 超级服务 xinetd

​ Linux因特网服务inetd是超级服务,它同时管理着多个子服务,即监听多个端口,现在Linux上使用的inetd服务程序通常是其升级版本xinetd,xinetd程序的原理与inetd的相同,但增加了一些控制选项,并提高了安全性

9.8.1 xinetd配置文件

​ xinetd采用/etc/xinetd.conf主配置文件和/etc/xinetd.d目录下的子配置文件来管理所有服务。

​ 主配置文件包含的是通用选项,这些选项将被所有子配置文件继承,但子配置文件可以覆盖这些选项,每一个子配置文件用于配置一个子服务的参数。

9.8.2 xinetd工作流程

​ xinetd管理的子服务中有的是标准服务,如时间日期服务daytime、回射服务echo、丢弃服务discard。xinetd服务器在内部直接处理这些服务。

​ 但还有的子服务需要调用外部服务器程序来处理,xinetd通过调用fork和exec来加载运行这些服务器程序,如telnet、ftp都是需调用的外部服务器程序。

​ 以下是wait选项的值是no时,xinetd的工作流程:

在这里插入图片描述

第10章 信号

10.1 Linux信号概述

10.1.1 发送信号

​ Linux下,一个进程给其他进程发送信号的API是kill函数:

#include <sys/types.h>
#include <signal.h>

// 把信号 sig 发给目标进程 pid
int kill(pid_t pid, int sig); // 成功返回 0,失败 -1 并设置 errno

​ 如果sig参数传为0,则kill函数不发送任何信号,此时可用来检测目标进程或进程组是否存在,因为检查工作总是在信号发送前执行,但这种检测方式不可靠,一方面是由于PID的回绕,导致被检测的PID不是我们期望的进程的PID,另一方面,这种检测方法不是原子操作(检测完进程可能就终止了)。

10.1.2 信号处理方式

​ 信号处理函数的原型为:

// 信号处理函数原型
typedef void (*__sighandler_t)(int);

​ 信号处理只带有一个整型参数,该参数用来指示信号类型。信号处理函数应该是可重入的,否则容易引发竞态条件,因此在信号处理函数中严禁调用不安全的函数。

​ 除了用户自定义信号处理函数外,bits/signum.h头文件中还定义了信号的另外两种处理方式:

#include <bits/signum.h>
#define SIG_DFL((__sighandler_t) 0)
#define SIG_IGN((__sighandler_t) 1)

​ SIG_IGN表示忽略目标信号,SIG_DFL表示使用信号的默认处理方式。信号的默认处理方式有以下几种:结束进程(Term)、忽略信号(Ign)、结束进程并生成核心转储文件(Core)、暂停进程(Stop)、继续进程(Cont)。

10.1.3 Linux信号

​ Linux的可用信号都定义在bits/signum.h头文件中,其中包括标准信号和POSIX实时信号

​ 和网络编程关系紧密的是:

  • SIGHUP:控制终端挂起
  • SIGPIPE:往读端被关闭的管道或者 socket 连接中写数据
  • SIGURG:socket 连接上接受到紧急数据
  • SIGALRM:由 alarm 或 setitimer 设置的实时闹钟超时引起
  • SIGCHLD:子进程状态发生变化(退出或暂停)
10.1.4 中断系统调用

​ 如果程序在执行处于阻塞状态的系统调用时收到信号,且我们为该信号设置了信号处理函数,则默认情况下该系统调用会被中断,并将errno设置为EINTR。我们可使用sigaction函数为信号设置SA_RESTART标志以自动重启被该信号中断的系统调用。

​ 对于默认行为是暂停进程的信号(如SIGSTOP、SIGTTIN),如果我们没有为它们设置信号处理函数,则它们也可以中断某些系统调用(如connect、epoll_wait函数),POSIX没有规定这种行为,这是Linux实现的行为。

10.2 信号函数

10.2.1 signal系统调用

​ 可用signal系统调用为一个信号设置处理函数:

#include <signal.h>

// sig 参数指出要捕获的信号类型,
// _handler 参数是函数指针,用于指定信号 sig 的处理函数
_sighandler_t signal(int sig, _sighandler_t handler);
10.2.2 sigaction系统调用

​ 更健壮的接口是sigaction系统调用:

#include <signal.h>

// sig 指出要捕获的信号类型
// act 参数指定新的信号处理方式
// oact 输出信号先前的处理方式(如果不为 NULL 的话)
// [return] 成功 0,失败 -1 并设置 errno
int sigaction(int sig, const struct sigaction* act, struct sigaction* oact);

struct sigaction {...}; // 参考 P181

在这里插入图片描述

​ sigaction结构体中的sa_handler成员指定信号处理函数;sa_mask成员设置进程的信号掩码(在进程原有信号掩码的基础上增加信号掩码),以指定在该信号的处理函数期间哪些信号不能发送给本进程,该成员类型是信号集类型sigset_t(_sigset_t的同义词),该类型指定一组信号。

10.3 信号集

10.3.1 信号集函数

​ Linux使用数据结构sigset_t表示一组信号:

#include <bits/sigset.h>

# define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
tydedef struct {
    unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t; // 其实就是一个长整型数组,数组的每个元素的每个位表示一个信号

​ Linux提供了以下函数来设置、修改、删除、查询信号集:

在这里插入图片描述

10.3.2 进程信号掩码

​ 以下函数可用于设置或查看进程的信号掩码:

#include <signal.h>

// _set 参数指定新的信号掩码,_how 指定设置掩码方式 SIG_BLOCK|SIG_UNBLOCK|SIG_SETMASK
// _oset 参数输出原来的信号掩码(如果不为 NULL 的话)
// [return] 成功 0,失败 -1 并设置 errno
int sigprocmask(int _how, _const sigset_t* _set, sigset_t* _oset);
10.3.3 被挂起的信号

设置进程信号掩码后,被屏蔽的信号将不能被进程接收。如果给进程发送一个被屏蔽的信号,则操作系统将该信号设置为进程的一个被挂起的信号

​ 如果我们取消对被挂起信号的屏蔽,则它能立即被进程接收到。如下函数可以获得进程当前被挂起的信号集:

#include <signal.h>

// 获取进程当前被挂起的信号集
// [return] 成功 0,失败 -1 并设置 errno
int sigpending(sigset_t* set);

​ set参数返回被挂起的信号集。进程即使多次接收到同一个被挂起的信号,sigpengding函数也只能返回一次(set参数的类型决定了它只能反映信号是否被挂起,不能反映被挂起的次数),并且,当我们再次使用sigprocmask函数使能该挂起的信号时,该信号的处理函数也只触发一次。

10.4 统一事件源

信号是一种异步事件:信号处理函数和进程的主循环是两条不同的执行路线,我们希望信号处理函数尽可能快地执行完毕,以确保该信号不被屏蔽太久(信号在处理期间,为了避免一些竞态条件,系统不会再触发它)。一种典型的解决方案是:把信号的主要处理逻辑放在进程的主循环中,当信号处理函数被触发时,它只是简单地通知主循环程序接收到信号,并把信号值传递给主循环,主循环再根据接收到的信号值执行目标信号对应的逻辑代码。信号处理函数通常使用管道将信号通知主循环,信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出该信号值,主循环中使用IO复用系统调用来监听管道的读端文件描述符上的可读事件,这样,信号事件就能和其他IO事件一样被处理,即统一事件源

​ 统一事件源的一个简单实现:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>
#include <libgen.h>

#define MAX_EVENT_NUMBER 1024

static int pipefd[2];

int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}

void addfd(int epollfd, int fd) {
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET;
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
    setnonblocking(fd);
}

// 信号处理函数
void sig_handler(int sig) {
    // 保留原来的errno,在函数最后恢复,保证函数的可重入性
    int save_errno = errno;
    int msg = sig;
    // 将信号写入管道,以通知主循环,此处代码是错误的,只发送了int的低地址1字节
    // 如果系统是大端字节序,则发送的永远是0,因此可以改成发送一个int,或将sig改为网络字节序,然后发送最后一个字节
    send(pipefd[1], (char *)&msg, 1, 0);
    errno = save_errno;
}

// 设置信号的处理函数
void addsig(int sig) {
    struct sigaction sa;
    memset(&sa, '\0', sizeof(sa));
    sa.sa_handler = sig_handler;
    sa.sa_flags |= SA_RESTART;
    sigfillset(&sa.sa_mask);
    assert(sigaction(sig, &sa, NULL) != -1);
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);

    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    if (ret == -1) {
        printf("errno is %d\n", errno);
        return 1;
    }
    ret = listen(listenfd, 5);
    assert(ret != -1);

    epoll_event events[MAX_EVENT_NUMBER];
    int epollfd = epoll_create(5);
    assert(epollfd != -1);
    addfd(epollfd, listenfd);

    // 使用socketpair创建管道,注册pipefd[0]上的可读事件
    ret = socketpair(PF_UNIX, SOCK_STREAM, 0, pipefd);
    assert(ret != -1);
    setnonblocking(pipefd[1]);
    addfd(epollfd, pipefd[0]);

    // 设置一些信号的处理函数
    addsig(SIGHUP);
    addsig(SIGCHLD);
    addsig(SIGTERM);
    addsig(SIGINT);
    bool stop_server = false;

    while (!stop_server) {
        int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
        if ((number < 0) && (errno != EINTR)) {
            printf("epoll failure\n");
            break;
        }

        for (int i = 0; i < number; ++i) {
            int sockfd = events[i].data.fd;
            // 如果就绪的文件描述符是listenfd,则处理新的连接
            if (sockfd == listenfd) {
                struct sockaddr_in client_address;
                socklen_t client_addrlength = sizeof(client_address);
                int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
                addfd(epollfd, connfd);
            // 如果就绪的文件描述符是pipefd[0],则处理信号
            } else if ((sockfd == pipefd[0]) && (events[i].events & EPOLLIN)) {
                int sig;
                char signals[1024];
                ret = recv(pipefd[0], signals, sizeof(signals), 0);
                if (ret == -1) {
                    continue;
                } else if (ret == 0) {
                    continue;
                } else {
                    // 每个信号占1字节,所以按字节逐个接收信号,我们用SIGERTM信号为例说明如何安全终止服务器主循环
                    for (int i = 0; i < ret; ++i) {
                        switch (signals[i]) {
                            case SIGCHLD:
                            case SIGHUP: 
                                continue;
                            case SIGTERM:
                            case SIGINT:
                                stop_server = true;
                        }
                    }
                }
            }
        }
    }

    printf("close fds\n");
    close(listenfd);
    close(pipefd[1]);
    close(pipefd[0]);
    return 0;
}

10.5 网络编程相关的信号

10.5.1 SIGHUP

​ **当挂起进程的控制终端时(关闭终端),SIGHUP信号将被触发。**对于没有控制终端的网络后台进程而言,它们通常利用SIGHUP信号来强制服务器重读配置文件,一个典型的例子是xinetd超级服务器。

​ xinetd进程在接收到SIGHUP信号后将调用hard_reconfig函数(见xinetd源码),它循环读取/etc/xinetd.d目录下的每个子配置文件,并检测其变化,如果某个正在运行的子服务的配置文件被修改以停止服务,则xinetd主进程将给该子进程发送SIGTERM信号以结束它。如果某个子服务的配置文件被修改以开启服务,则xinetd将创建新socket并将其绑定到该服务对应的端口上。

10.5.2 SIGPIPE

​ 默认,往一个读端关闭的管道或已关闭的socket连接中写数据将引发SIGPIPE信号

​ 我们需要在代码中捕获并处理该信号,或者至少忽略它,因为它的默认行为是结束进程,而我们绝对不希望因为错误的写操作而导致程序退出。引起SIGPIPE信号的写操作将设置errno为EPIPE。

​ 我们可用send函数的MSG_NOSIGNAL标志来禁止写操作触发SIGPIPE信号,此时,我们应使用send函数反馈的errno值来判断管道的读端或socket连接是否已经关闭。

​ 此外,我们也可利用IO复用系统调用来检测管道读端和socket是否已经关闭,以poll函数为例当管道的读端关闭时,写端文件描述符上的POLLHUP事件将被触发,当socket连接被对方关闭或对方只关闭了写端时,socket上的POLLRDHUP事件将被触发

10.5.3 SIGURG

​ 在Linux环境下,内核通知应用进程带外数据到达主要有两种方法,一种是IO复用技术,select等系统调用在接收到带外数据时将返回,并向应用进程报告socket上的异常事件,如代码清单9-1,另一种是使用SIGURG信号,如下代码所示:

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <signal.h>
#include <fcntl.h>
#include <libgen.h>

#define BUF_SIZE 1024

static int connfd;

// SIGURG信号的处理函数
void sig_urg(int sig) {
    int save_errno = errno;
    char buffer[BUF_SIZE];
    memset(buffer, '\0', BUF_SIZE);
    // 接收带外数据,只有SO_OOBINLINE套接字选项未开启时才能这样读带外数据,否则recv函数会返回EINVAL
    // 此处代码有一个bug,当我方接收缓冲区已满,而对方进入紧急状态时,会发一个不含数据的TCP报文段
    // 来指示对端进入了紧急状态,我方接收到这个TCP报文段后就会给本进程发送SIGURG信号
    // 但我们还未收到这个紧急字节,此时recv函数会返回EWOULDBLOCK,我们应该一直读connfd
    // 以便在接收缓冲区中腾出空间,继而允许对端TCP发送那个带外字节
    int ret = recv(connfd, buffer, BUF_SIZE - 1, MSG_OOB);
    printf("got %d bytes of oob data '%s'\n", ret, buffer);
    errno = save_errno;
}

void addsig(int sig, void (*sig_handler)(int)) {
    struct sigaction sa;
    memset(&sa, '\0', sizeof(sa));
    sa.sa_handler = sig_handler;
    sa.sa_flags |= SA_RESTART;
    sigfillset(&sa.sa_mask);
    assert(sigaction(sig, &sa, NULL) != -1);
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int sock = socket(PF_INET, SOCK_STREAM, 0);
    assert(sock >= 0);

    int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(sock, 5);
    assert(ret != -1);

    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof(client);
    connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);
    if (connfd < 0) {
        printf("errno is: %d\n", errno);
    } else {
        addsig(SIGURG, sig_urg);
        // 我们必须设置socket的宿主进程或进程组
        fcntl(connfd, F_SETOWN, getpid());

        char buffer[BUF_SIZE];
        // 循环接收普通数据
        while (1) {
            memset(buffer, '\0', BUF_SIZE);
            ret = recv(connfd, buffer, BUF_SIZE - 1, 0);
            if (ret <= 0) {
                break;
            }
            printf("get %d bytes of normal data '%s'\n", ret, buffer);
        }

        close(connfd);
    }

    close(sock);
    return 0;
}
10.5.4 TCP带外数据总结

​ 至此,我们讨论完了TCP带外数据相关的所有知识。

  • 3.8节中我们介绍了TCP带外数据的基本知识,其中探讨了TCP模块是如何发送和接收带外数据的
  • 5.8.1小节描述了如何在应用程序中使用带MSG_OOB标志的send/recv系统调用来发送/接收带外数据,并给出了相关代码。
  • 9.1.3小节和10.5.3小节分别介绍了检测带外数据是否到达的两种方法:IO复用系统调用报告的异常事件和SIGURG信号。
  • 5.9节介绍(但应用程序检测到带外数据到达后,我们还需要进一步判断带外数据在数据流中的具体位置,才能够准确无误地读取带外数据。)的sockatmark系统调用就是专门用于解决这个问题的。它判断一个socket是否处于带外标记,即该socket 上下一个将被读取到的数据是否是带外数据。

第11章 定时器

​ 两种高效的管理定时器的容器:时间轮和时间堆。

定时指一段时间后触发某段代码的机制,我们可以在这段代码中依次处理所有到期的定时器,即定时机制是定时器得以被处理的原动力。

​ Linux提供三种定时方法:

  • 1.socket套接字选项SO_RCVTIMEO和SO_SNDTIMEO。

  • 2.SIGALRM信号。

  • 3.IO复用系统调用的超时参数。

11.1 socket 选项 SO_RCVTIMEO/SO_SNDTIMEO

​ SO_RCVTIMEO和SO_SNDTIMEO分别用来设置socket接收和发送数据的超时时间。

​ 这两个socket选项只对socket专用的数据接收和发送系统调用有效,作者给出的专用系统调用为send、sendmsg、recv、recvmsg、accept、connect

在这里插入图片描述

​ 由上表,我们可以根据系统调用的返回值和errno来判断超时时间是否已到,进而决定是否开始处理定时任务,下面以connect函数为例,说明SO_SNDTIMEO套接字选项如何来定时:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <assert.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <libgen.h>

// 执行超时connect
int timeout_connect(const char *ip, int port, int time) {
    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int sockfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(sockfd >= 0);
    
    // SO_RCVTIMEO和SO_SNDTIMEO套接字选项对应的值类型为timeval,这和select函数的超时参数类型相同
    struct timeval timeout;
    timeout.tv_sec = time;
    timeout.tv_usec = 0;
    socklen_t len = sizeof(timeout);
    ret = setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &timeout, len);
    assert(ret != -1);

    ret = connect(sockfd, (struct sockaddr *)&address, sizeof(address));
    if (ret == -1) {
        // 超时对应的错误号是EINPROGRESS,此时就可执行定时任务了
        if (errno == EINPROGRESS) {
            printf("conencting timeout, process timeout logic\n");
            return -1;
        }
        printf("error occur when connecting to server\n");
        return -1;
    }

    return sockfd;
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    int sockfd = timeout_connect(ip, port, 10);
    if (sockfd < 0) {
        return 1;
    }
    return 0;
}

11.2 SIGALRM 信号

​ 第10章提到,由alarm 和 setitimer 兩数设置的实时闹钟一旦超时,将触发 SIGALRM 信号。因此可以利用该信号的信号处理函数来处理定时任务

​ 但如果要处理多个定时任务,我们就需要不断触发SIGALRM信号。一般,SIGALRM信号按固定的频率生成,即由alarm或setitimer函数设置的定时周期T保持不变,如果某个定时任务的超时时间不是T的整数倍,那么它实际被执行的时间和预期的时间将略有偏差,因此定时周期T反映了定时的精度。

11.2.1 基于升序链表的定时器

定时器通常至少要包含两个成员:一个超时时间(相对时间或绝对时间)和一个任务回调函数。有时还可能包含回调函数被执行时需要传入的参数,以及是否重启定时器等信息。如果使用链表作为容器来串联所有定时器,则每个定时器还要包含指向下一定时器的指针成员,如果链表是双向的,则每个定时器还需要包含指向前一个定时器的指针成员。

以下代码实现了简单的升序定时器链表,其中的定时器按超时时间做升序排序:

#ifndef LST_TIMER
#define LST_TIMER

#include <time.h>
#include <netinet/in.h>
#include <stdio.h>

#define BUFFER_SIZE 64

// 前向声明,我们需要在client_data结构中定义该结构的指针类型
class util_timer;

// 用户数据结构
struct client_data {
    // 客户socket地址
    sockaddr_in address;
    // socket文件描述符
    int sockfd;
    // 读缓冲区
    char buf[BUFFER_SIZE];
    // 定时器
    util_timer *timer;
};

// 定时器类
class util_timer {
public:
    util_timer() : prev(NULL), next(NULL) {}

    // 任务执行时间,UNIX时间戳
    time_t expire;
    // 任务回调函数
    void (*cb_func)(client_data *);
    // 回调函数处理的客户数据,由定时器的执行者传给回调函数
    client_data *user_data;
    // 指向前一个定时器
    util_timer *prev;
    // 指向后一个定时器
    util_timer *next;
};

// 定时器链表,它是一个升序、双向链表,且有头节点和尾节点
class sort_timer_lst {
public:
    sort_timer_lst() : head(NULL), tail(NULL) {}
    // 链表被删除时,删除其中所有定时器
    ~sort_timer_lst() {
        util_timer *tmp = head;
        while (tmp) {
            head = tmp->next;
            delete tmp;
            tmp = head;
        }
    }

    // 将目标定时器timer参数添加到链表中
    void add_timer(util_timer *timer) {
        if (!timer) {
            return;
        }
        if (!head) {
            head = tail = timer;
            return;
        }
        
        // 如果timer中的执行时间小于链表中所有定时器的超时时间,则将其放在链表头部
        if (timer->expire < head->expire) {
            timer->next = head;
            head->prev = timer;
            head = timer;
            return;
        }
        // 否则调用重载函数add_timer(util_timer, util_timer)将其放到链表中合适的位置,以保证链表的升序特性
        add_timer(timer, head);
    }

    // 调整定时任务的执行时间,本函数只处理执行时间延后的情况,即将该定时器向链表尾部移动
    void adjust_timer(util_timer *timer) {
        if (!timer) {
            return;
        }
        util_timer *tmp = timer->next;
        // 如果被调整的目标定时器在链表尾,或该定时器的超时值仍小于下一个定时器的超时值,则不用调整
        if (!tmp || (timer->expire < tmp->expire)) {
            return;
        }
        // 如果目标定时器在链表头,则将该定时器从链表中取出并重新插入链表
        if (timer == head) {
            head = head->next;
            head->prev = NULL;
            timer->next = NULL;
            add_timer(timer, head);
        // 如果目标定时器不是链表头节点,则将该定时器从链表中取出,然后插入其原来所在位置之后的链表中
        } else {
            timer->prev->next = timer->next;
            timer->next->prev = timer->prev;
            add_timer(timer, timer->next);
        }
    }

    // 将目标定时器timer从链表中删除
    void del_timer(util_timer *timer) {
        if (!timer) {
            return;
        }
        // 当链表中只有要删除的那个定时器时
        if ((timer == head) && (timer == tail)) {
            delete timer;
            head = NULL;
            tail = NULL;
            return;
        }
        // 如果链表中至少有两个定时器,且目标定时器时链表头节点,则将链表的头节点重置为原头节点的下一个节点
        if (timer == head) {
            head = head->next;
            head->prev = NULL;
            delete timer;
            return;
        }
        // 如果链表中至少有两个定时器,且目标定时器时链表尾节点,则将链表的尾节点重置为原尾节点的前一个节点
        if (timer == tail) {
            tail = tail->prev;
            tail->next = NULL;
            delete timer;
            return;
        }
        // 如果,目标定时器位于链表中间,则把它前后的定时器串联起来,然后删除目标定时器
        timer->prev->next = timer->next;
        timer->next->prev = timer->prev;
        delete timer;
    }

    // SIGALRM信号每次触发就在其信号处理函数(如果使用统一事件源,则是主函数)中执行一次tick函数,处理链表上的到期任务
    void tick() {
        if (!head) {
            return;
        }
        printf("timer tick\n");
        // 获得系统当前UNIX时间戳
        time_t cur = time(NULL);
        util_timer *tmp = head;

        // 从头节点开始依次处理每个定时器,直到遇到一个尚未到期的定时器
        while (tmp) {
            // 每个定时器都使用绝对时间作为超时值,因此我们可把定时器的超时值和系统当前时间作比较
            if (cur < tmp->expire) {
                break;
            }
            // 调用定时器的回调函数,以执行定时任务
            tmp->cb_func(tmp->user_data);
            // 执行完定时器中的任务后,将其从链表中删除,并重置链表头节点
            head = tmp->next;
            if (head) {
                head->prev = NULL;
            }
            delete tmp;
            tmp = head;
        }
    }

private:
    // 一个重载的辅助函数,它被公有的add_timer和adjust_timer函数调用
    // 该函数将目标定时器timer参数添加到节点lst_head参数后的链表中
    void add_timer(util_timer *timer, util_timer *lst_head) {
        util_timer *prev = lst_head;
        util_timer *tmp = prev->next;
        // 遍历lst_head节点后的部分链表,直到找到一个超时时间大于目标定时器超时时间的节点,并将目标定时器插入该节点前
        while (tmp) {
            if (timer->expire < tmp->expire) {
                prev->next = timer;
                timer->next = tmp;
                tmp->prev = timer;
                timer->prev = prev;
                break;
            }
            prev = tmp;
            tmp = tmp->next;
        }

        // 如果遍历完lst_head节点后的链表,仍未找到超时时间大于目标定时器的超时时间的节点,则将目标定时器作为链表尾
        if (!tmp) {
            prev->next = timer;
            timer->prev = prev;
            timer->next = NULL;
            tail = timer;
        }
    }

    util_timer *head;
    util_timer *tail;
};
#endif

​ sort_timer_lst类是一个升序链表,其核心函数tick相当于心博函数,它每隔一段固定的时间执行一次,以检测并处理到期的任务。

11.2.2 处理非活动连接

​ 服务器进程通常要定期处理非活动连接,可这样处理非活动的连接:给客户端发一个重连请求,或关闭该连接,或者其他。

​ Linux在内核中提供了对连接是否处于活动状态的定期检查机制,我们可通过socket选项KEEPALIVE来激活它。

​ 我们在应用层实现类似KEEPALIVE的机制,以管理所有长时间处于非活动状态的连接,如以下代码利用alarm函数周期性地触发SIGALRM信号,该信号的信号处理函数利用管道通知主循环执行定时器链表上的定时任务——关闭非活动的连接:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>
#include <libgen.h>

#include "lst_timer.h"

#define FD_LIMIT 65535
#define MAX_EVENT_NUMBER 1024
#define TIMESLOT 5

static int pipefd[2];
// 用升序链表来管理定时器
static sort_timer_lst timer_lst;
static int epollfd = 0;

int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}

void addfd(int epollfd, int fd) {
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET;
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
    setnonblocking(fd);
}

void sig_handler(int sig) {
    int save_errno = errno;
    int msg = sig;
    // 此处还是老bug,没有考虑字节序就发送了int的低地址的1字节
    send(pipefd[1], (char *)&msg, 1, 0);
    errno = save_errno;
}

void addsig(int sig) {
    struct sigaction sa;
    memset(&sa, '\0', sizeof(sa));
    sa.sa_handler = sig_handler;
    sa.sa_flags |= SA_RESTART;
    sigfillset(&sa.sa_mask);
    assert(sigaction(sig, &sa, NULL) != -1);
}

void timer_handler() {
    // 处理定时任务
    timer_lst.tick();
    // 由于alarm函数只会引起一次SIGALRM信号,因此重新定时,以不断触发SIGALRM信号
    alarm(TIMESLOT);
}

// 定时器回调函数,它删除非活动连接socket上的注册事件,并关闭之
void cb_func(client_data *user_data) {
    epoll_ctl(epollfd, EPOLL_CTL_DEL, user_data->sockfd, 0);
    assert(user_data);
    close(user_data->sockfd);
    printf("close fd %d\n", user_data->sockfd);
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);

    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(listenfd, 5);
    assert(ret != -1);

    epoll_event events[MAX_EVENT_NUMBER];
    int epollfd = epoll_create(5);
    assert(epollfd != -1);
    addfd(epollfd, listenfd);

    ret = socketpair(PF_UNIX, SOCK_STREAM, 0, pipefd);
    assert(ret != -1);
    setnonblocking(pipefd[1]);
    addfd(epollfd, pipefd[0]);

    // 设置信号处理函数
    addsig(SIGALRM);
    addsig(SIGTERM);
    bool stop_server = false;

    // 直接初始化FD_LIMIT个client_data对象,其数组索引是文件描述符
    client_data *users = new client_data[FD_LIMIT];
    bool timeout = false;
    // 定时
    alarm(TIMESLOT);

    while (!stop_server) {
        int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
        if ((number < 0) && (errno != EINTR)) {
            printf("epoll failure\n");
            break;
        }

        for (int i = 0; i < number; ++i) {
            int sockfd = events[i].data.fd;
            // 处理新到的客户连接
            if (sockfd == listenfd) {
                struct sockaddr_in client_address;
                socklen_t client_addrlength = sizeof(client_address);
                int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
                addfd(epollfd, connfd);
                users[connfd].address = client_address;
                users[connfd].sockfd = connfd;
                // 创建一个定时器,设置其回调函数和超时时间,然后绑定定时器和用户数据,并将定时器添加到timer_lst中
                util_timer *timer = new util_timer;
                timer->user_data = &users[connfd];
                timer->cb_func = cb_func;
                time_t cur = time(NULL);
                timer->expire = cur + 3 * TIMESLOT;
                users[connfd].timer = timer;
                timer_lst.add_timer(timer);
            // 处理信号
            } else if ((sockfd == pipefd[0]) && (events[i].events & EPOLLIN)) {
                int sig;
                char signals[1024];
                ret = recv(pipefd[0], signals, sizeof(signals), 0);
                if (ret == -1) {
                    // handle the error
                    continue;
                } else if (ret == 0) {
                    continue;
                } else {
                    for (int i = 0; i < ret; ++i) {
                        switch (signals[i]) {
                        case SIGALRM:
                            // 先标记为有定时任务,因为定时任务优先级比IO事件低,我们优先处理其他更重要的任务
                            timeout = true;
                            break;
                        
                        case SIGTERM:
                            stop_server = true;
                            break;
                        }
                    }
                }
            // 从客户连接上接收到数据
            } else if (events[i].events & EPOLLIN) {
                memset(users[sockfd].buf, '\0', BUFFER_SIZE);
                ret = recv(sockfd, users[sockfd].buf, BUFFER_SIZE - 1, 0);
                printf("get %d bytes of client data %s from %d\n", ret, users[sockfd].buf, sockfd);

                util_timer *timer = users[sockfd].timer;
                if (ret < 0) {
                    // 如果发生读错误,则关闭连接,并移除对应的定时器
                    if (errno != EAGAIN) {
                        cb_func(&users[sockfd]);
                        if (timer) {
                            timer_lst.del_timer(timer);
                        }
                    }
                } else if (ret == 0) {
                    // 如果对方关闭连接,则我们也关闭连接,并移除对应的定时器
                    cb_func(&users[sockfd]);
                    if (timer) {
                        timer_lst.del_timer(timer);
                    }
                } else {
                    // 如果客户连接上读到了数据,则调整该连接对应的定时器,以延迟该连接被关闭的时间
                    if (timer) {
                        time_t cur = time(NULL);
                        timer->expire = cur + 3 * TIMESLOT;
                        printf("adjust timer once\n");
                        timer_lst.adjust_timer(timer);
                    }
                }
            }
        }

        // 最后处理定时事件,因为IO事件的优先级更高,但这样会导致定时任务不能精确按预期的时间执行
        if (timeout) {
            timer_handler();
            timeout = false;
        }
    }

    close(listenfd);
    close(pipefd[1]);
    close(pipefd[0]);
    delete[] users;
    return 0;
}

11.3 I/O 复用系统调用的超时参数

Linux下的3组IO复用系统调用都带有超时参数,因此它们不仅能统一处理信号(通过管道在信号处理函数中通知主进程)和IO事件,也能统一处理定时事件,但由于IO复用系统调用可能在超时时间到期前就返回(有IO事件发生),所以如果我们要利用它们来定时,就需要不断更新定时参数以反映剩余的时间,如下代码所示:

#define TIMEOUT 5000

int timeout = TIMEOUT;
time_t start = time(NULL);
time_t end = time(NULL);
while (1) {
    printf("the timeout is now %d mil-seconds\n", timeout);
    start = time(NULL);
    int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, timeout);
    if ((number < 0) && (errno != EINTR)) {
        printf("epoll failure\n");
        break;
    }
    // 如果epoll_wait函数返回0,说明超时时间到,此时可处理定时任务,并重置定时时间
    if (number == 0) {
        // 处理定时任务
        timeout = TIMEOUT;
        continue;
    }
    
    // 到此,epoll_wait函数的返回值大于0,
    end = time(NULL);
    // 更新timeout的值为减去本次epoll_wait调用的持续时间
    timeout -= (end - start) * 1000;
    // 重新计算后的timeout值可能是0,说明本次epoll_wait调用返回时,不仅有文件描述符就绪,且其超时时间也刚好到达
    // 此时我们要处理定时任务,并充值定时时间
    if (timeout <= 0) {
        // 处理定时任务
        timeout = TIMEOUT;
    }

    // handle connections
}

11.4 高性能定时器

11.4.1 时间轮

​ 基于排序链表的定时器存在一个问题:添加定时器的效率偏低。下面讨论的时间轮解决了这个问题,一种简单的时间轮如下图:

在这里插入图片描述

​ 上图中,时间轮内部的实线指针指向轮子上的一个槽(slot),它以恒定的速度顺时针转动,每转动一步就指向下一个槽(虚线指针所指的槽),每次转动称为一个滴答(tick)一个滴答的时间称为时间轮的槽间隔si(slot interval),它实际上就是心博时间。

​ 上图中的时间轮共有N个槽,因此它转动一周的时间是N*si。每个槽指向一条定时器链表,每条链表上的定时器具有相同的特征:它们的定时事件相差N*si的整数倍,时间轮正是利用这个关系将定时器散列到不同的链表中。假如现在指针指向槽cs,我们要添加一个定时事件为ti的定时器,则该定时器将被插入槽ts(timer slot)对应的链表中:

t s = ( c s + ( t i / s i ) ) % N ts = (cs+(ti/si)) \% N ts=(cs+(ti/si))%N
基于排序链表的定时器使用唯一的一条链表来管理所有定时器,所以插入操作的效率随着定时器数目的增多而降低,而时间轮使用哈希表的思想,将定时器散列到不同的链表上,这样每条链表上的定时器数目都将明显少于原来的排序链表上的定时器数目,插入操作的效率基本不受定时器数目的影响。

对时间轮而言,要想提高定时精度,就要使si足够小,要提高执行效率,就要求N足够大(N越大,散列冲突的概率就越小)

​ 以下代码描述了一种简单的时间轮,因为它只有一个轮子,而复杂的时间轮可能有多个轮子,不同的轮子有不同的粒度,相邻的两个轮子,精度高的转一圈,精度低的仅仅往前移动一槽,就像水表一样:

#ifndef TIME_WHEEL_TIMER
#define TIME_WHEEL_THMER

#include <time.h>
#include <netinet/in.h>
#include <stdio.h>

#define BUFFER_SIZE 64

class tw_timer;

// 用户数据,包含socket和定时器
struct client_data {
    sockaddr_in address;
    int sockfd;
    char buf[BUFFER_SIZE];
    tw_timer *timer;
};

// 定时器类
class tw_timer {
public:
    tw_timer(int rot, int ts) : next(NULL), prev(NULL), rotation(rot), time_slot(ts) { }

    // 定时器在时间轮转多少圈后生效
    int rotation;
    // 定时器属于时间轮上的哪个槽(对应的链表)
    int time_slot;
    // 定时器回调函数
    void (*cb_func)(client_data *);
    // 客户数据
    client_data *user_data;
    // 指向下一个定时器
    tw_timer *next;
    // 指向前一个定时器
    tw_timer *prev;
};

class time_wheel {
public:
    time_wheel() : cur_slot(0) {
        for (int i = 0; i < N; ++i) {
            // 初始化每个槽的头节点
            slots[i] = NULL;
        }
    }

    ~time_wheel() {
        // 销毁每个槽中的所有定时器
        for (int i = 0; i < N; ++i) {
            tw_timer *tmp = slots[i];
            while (tmp) {
                slots[i] = tmp->next;
                delete tmp;
                tmp = slots[i];
            }
        }
    }

    // 根据定时值timeout参数创建一个定时器,并将它插入合适的槽中
    tw_timer *add_timer(int timeout) {
        if (timeout < 0) {
            return NULL;
        }
        int ticks = 0;
        // 计算待插入定时器的超时值在多少滴答后被触发
        // 如果待插入定时器的超时值小于时间轮的槽间隔,就将其向上折合成1
        if (timeout < SI) {
            ticks = 1;
        // 否则将其向下折合成timeout/SI
        } else {
            ticks = timeout / SI;
        }
        // 计算待插入的定时器在时间轮转动多少圈后触发
        int rotation = ticks / N;
        // 计算待插入的定时器应被插入哪个槽中
        int ts = (cur_slot + (ticks % N)) % N;
        // 创建新定时器,它在时间轮转动rotation圈后触发,且位于第ts个槽上
        tw_timer *timer = new tw_timer(rotation, ts);
        // 如果第ts个槽为空,则将新建的定时器作为该槽的头节点
        if (!slots[ts]) {
            printf("add timer, rotation is %d, ts is %d, cur_slot is %d\n", rotation, ts, cur_slot);
            slots[ts] = timer;
        // 否则,将定时器插入第ts个槽中,也作为头节点
        } else {
            timer->next = slots[ts];
            slots[ts]->prev = timer;
            slots[ts] = timer;
        }
        return timer;
    }

    // 删除目标定时器
    void del_timer(tw_timer *timer) {
        if (!timer) {
            return;
        }
        int ts = timer->time_slot;
        // slots[ts]是目标定时器时,说明目标定时器是该槽的头节点,此时需要重置第ts个槽的头节点
        if (timer == slots[ts]) {
            slots[ts] = slots[ts]->next;
            if (slots[ts]) {
                slots[ts]->prev = NULL;
            }
            delete timer;
        } else {
            timer->prev->next = timer->next;
            if (timer->next) {
                timer->next->prev = timer->prev;
            }
            delete timer;
        }
    }
    
    // SI时间到后,调用该函数,时间轮向前滚动一个槽的间隔
    void tick() {
        // 取得时间轮上当前槽的头节点
        tw_timer *tmp = slots[cur_slot];
        printf("current slot is %d\n", cur_slot);
        while (tmp) {
            printf("tick the timer once\n");
            // 如果定时器的rotation值大于0,则它在这一轮不起作用
            if (tmp->rotation > 0) {
                --tmp->rotation;
                tmp = tmp->next;
            // 否则说明定时器已到期,于是执行定时任务,然后删除该定时器
            } else {
                tmp->cb_func(tmp->user_data);
                if (tmp == slots[cur_slot]) {
                    printf("delete header in cur_slot\n");
                    slots[cur_slot] = tmp->next;
                    delete tmp;
                    if (slots[cur_slot]) {
                        slots[cur_slot]->prev = NULL;
                    }
                    tmp = slots[cur_slot];
                } else {
                    tmp->prev->next = tmp->next;
                    if (tmp->next) {
                        tmp->next->prev = tmp->prev;
                    }
                    tw_timer *tmp2 = tmp->next;
                    delete tmp;
                    tmp = tmp2;
                }
            }
        }

        // 更新时间轮的当前值,以反映时间轮转动
        cur_slot = ++cur_slot % N;
    }

private:
    // 时间轮上的槽数
    static const int N = 60;
    // 每1秒时间轮转动一次,即槽间隔为1秒
    static const int SI = 1;
    // 时间轮上的槽,其中每个元素指向一个定时器的链表,链表无序
    tw_timer *slots[N];
    // 时间轮的当前槽
    int cur_slot;
};

#endif
11.4.2 时间堆

​ 以上讨论的定时方案都是以固定频率调用心博函数tick,并依次检测到期的定时器,然后执行定时器上的回调函数。

​ 设计定时器的另一种思路是:将所有定时器中超时时间最小的定时器的超时值作为心博间隔,这样,一旦心博函数tick被调用,超时时间最小的定时器必然到期,我们就可在tick函数中处理该定时器,然后,再次从剩余定时器中找出超时时间最小的一个,并将这段最小时间设为下一次心博间隔。

​ 最小堆很适合这种定时方案,最小堆是每个节点的值都小于或等于其子节点的值的完全二叉树

​ 我们可用数组来组织最小堆中的元素

​ 对于数组中任意位置i上的元素,其左儿子节点在位置2i+1上,其右儿子在位置2i+2上,其父节点在[(i-1)/2]上。

​ 与用链表来表示堆相比,用数组表示堆不仅节省空间,且更容易实现堆的插入、删除等操作

​ 假设我们已经有一个包含N个元素的数组,现在把它初始化为一个最小堆,实际上,我们只需对数组中第[(N-1)/2]到0个元素执行下虑操作,即可保证该数组构成一个最小堆,因为对包含N个元素的完全二叉树而言,它具有[(N-1)/2]个非叶子节点,这些非叶子节点正是该完全二叉树的第0到[(N-1)/2]个节点,我们只需确保这些非叶子节点构成的子树具有堆序性质,整个树就具有堆序性质。

​ 我们称用最小堆实现的定时器为时间堆,以下给出一种时间堆的实现,其中最小堆使用数组表示:

#ifndef MIN_HEAP
#define MIN_HEAP

#include <iostream>
#include <netinet/in.h>
#include <time.h>
using std::exception;

#define BUFFER_SIZE 64

// 前向声明
class heap_timer;

// 客户数据,绑定socket和定时器
struct client_data {
    sockaddr_in address;
    int sockfd;
    char buf[BUFFER_SIZE];
    heap_timer *timer;
};

// 定时器类
class heap_timer {
public:
    heap_timer(int delay) {
        expire = time(NULL) + delay;
    }

    // 定时器生效的绝对时间
    time_t expire;
    // 定时器的回调函数
    void (*cb_func)(client_data *);
    // 用户数据
    client_data *user_data;
};

// 时间堆类
class time_heap {
public:
    // 构造函数一,初始化一个大小为cap的空堆
    // throw (std::exception)是异常规范指出函数可能抛出的异常类型,是给调用者的提示而非强制性的规则
    // 在C++11中,异常规范已经被弃用
    // 上面作者已经使用了using将std::exception引入了当前作用域,此处可以不加std::前缀
    time_heap(int cap) throw (std::exception) : capacity(cap), cur_size(0) {
        // 创建堆数组
        // 此处代码是错的,new在分配失败时默认会抛出std::bad_alloc异常
        array = new heap_timer* [capacity];
        if (!array) {
            throw std::exception();
        }
        for (int i = 0; i < capacity; ++i) {
            array[i] = NULL;
        }
    }

    // 构造函数二,用已有代码初始化堆
    time_heap(heap_timer **init_array, int size, int capacity) throw (std::exception) 
              : cur_size(size), capacity(capacity) {
        if (capacity < size) {
            throw std::exception();
        }          
        // 创建堆数组
        // 此处代码是错的,new在分配失败时默认会抛出std::bad_alloc异常
        array = new heap_timer* [capacity];
        if (!array) {
            throw std::exception();
        }
        for (int i = 0; i < capacity; ++i) {
            array[i] = NULL;
        }
        if (size != 0) {
            // 初始化堆数组
            for (int i = 0; i < size; ++i) {
                array[i] = init_array[i];
            }
            // 对数组中第[(cur_size - 1) / 2]到0之间的元素执行下虑操作
            for (int i = (cur_size - 1) / 2; i >= 0; --i) {
                percolate_down(i);
            }
        }
    }

    // 销毁时间堆
    ~time_heap() {
        for (int i = 0; i < cur_size; ++i) {
            delete array[i];
        }
        delete[] array;
    }

    // 添加定时器timer
    void add_timer(heap_timer *timer) throw (std::exception) {
        if (!timer) {
            return;
        }
        // 如果当前堆数组容量不足,则将其扩大
        if (cur_size >= capacity) {
            resize();
        }
        // 新插入了一个元素,当前堆大小加1,hole是新建空穴的位置
        int hole = cur_size++;
        int parent = 0;
        // 对从空穴到根节点路径上的所有节点执行上虑操作
        for (; hole > 0; hole = parent) {
            parent = (hole - 1) / 2;
            if (array[parent]->expire <= timer->expire) {
                break;
            }
            array[hole] = array[parent];
        }
        array[hole] = timer;
    }

    // 删除定时器
    void del_timer(heap_timer *timer) {
        if (!timer) {
            return;
        }
        // 仅仅将目标定时器的回调函数设置为空,即所谓的延迟销毁
        // 这样节省真正删除该定时器造成的开销,但容易使堆数组膨胀
        timer->cb_func = NULL;
    }

    // 获得堆顶的定时器
    // const成员函数不会改变对象中的数据成员
    heap_timer *top() const {
        if (empty()) {
            return NULL;
        }
        return array[0];
    }

    // 删除堆顶的定时器
    void pop_timer() {
        if (empty()) {
            return;
        }
        if (array[0]) {
            delete array[0];
            // 将原来堆顶元素替换为堆数组中最后一个元素
            array[0] = array[--cur_size];
            // 对新的堆顶元素执行下虑操作
            percolate_down(0);
        }
    }

    // 心博函数
    void tick() {
        heap_timer *tmp = array[0];
        time_t cur = time(NULL);
        // 循环处理堆中到期的定时器
        while (!empty()) {
            if (!tmp) {
                break;
            }
            // 如果堆顶定时器没到期,则退出循环
            if (tmp->expire > cur) {
                break;
            }
            // 否则就执行堆顶定时器中的任务
            if (array[0]->cb_func) {
                array[0]->cb_func(array[0]->user_data);
            }
            // 删除堆顶元素
            pop_timer();
            tmp = array[0];
        }
    }

    bool empty() const {
        return cur_size == 0;
    }

private:
    // 最小堆的下虑操作,它确保数组中以第hole个节点作为根的子树拥有最小堆性质
    void percolate_down(int hole) {
        heap_timer *temp = array[hole];
        int child = 0;
        for (; (hole * 2 + 1) <= (cur_size - 1); hole = child) {
            child = hole * 2 + 1;
            // child < cur_size - 1保证了hole有右子节点
            if ((child < cur_size - 1) && (array[child + 1]->expire < array[child]->expire)) {
                ++child;
            }
            if (array[child]->expire < temp->expire) {
                array[hole] = array[child];
            } else {
                break;
            }
        }
        array[hole] = temp;
    }

    // 将堆数组容量扩大一倍
    void resize() throw (std::exception) {
        heap_timer **temp = new heap_timer* [2 * capacity];
        for (int i = 0; i < 2 * capacity; ++i) {
            temp[i] = NULL;
        }
        if (!temp) {
            throw std::exception();
        }
        capacity = 2 * capacity;
        for (int i = 0; i < cur_size; ++i) {
            temp[i] = array[i];
        }
        delete[] array;
        array = temp;
    }

    // 堆数组
    heap_timer **array;
    // 堆数组的容量
    int capacity;
    // 堆数组中当前包含元素的个数
    int cur_size;
};

#endif

第12章 高性能I/O框架库Libevent

Linux服务器进程在处理三类事件(IO、信号、定时)时需要考虑以下问题:

  • 1.统一事件源。统一处理这三类事件既能使代码简单易懂,又能避免一些潜在的逻辑错误,可用IO复用系统调用来管理所有事件。
  • 2.可移植性。不同的操作系统有不同的IO复用方式,如Solaris的/dev/poll文件、FreeBSD的kqueue机制、Linux的epoll系列系统调用。
  • 3.对并发编程的支持。在多进程和多线程环境下,我们需要考虑各执行实体如何协同处理客户连接、信号、定时器,以避免竞态条件。

12.1 I/O框架库概述

​ 基于Reactor模式的IO框架库包含以下组件:句柄(Handle)、事件多路分发器(EventDemultiplexer)、事件处理器(EventHandler)、具体的事件处理器(ConcreteEventHandler)、Reactor。这些组件的关系见下图:

在这里插入图片描述

  • 1.句柄(handle)。IO框架库要处理的对象,即IO事件、信号、定时事件,统一称为事件源。一个事件源通常和一个句柄绑定在一起。句柄的作用是,当内核检测到就绪事件时,它将通过句柄来通知应用进程这一事件。在Linux环境下,IO事件对应的句柄是文件描述符,信号事件对应的句柄就是信号值
  • 2.事件多路分发器(EventDemultiplexer)。事件的到来时随机的、异步的,我们无法预知进程何时收到一个客户连接请求,或收到一个暂停信号,所以进程需要循环地等待并处理事件,这就是事件循环,在事件循环中,等待时间一般使用IO复用技术来实现IO框架库一般将系统支持的各种IO复用系统调用封装成统一的接口,称为事件多路分发器。事件多路分发器的demultiplex方法是等待事件的核心函数,其内部调用的是select、poll、epoll_wait等函数。此外,事件多路分发器还需实现register_event和remove_event方法,以供调用者往事件多路分发器中添加事件和从事件多路分发器中删除事件。
  • 3.事件处理器和具体时间处理器(EventHandler and ConcreteEventHandler)。事件处理器执行事件对应的业务逻辑,它通常包含一个或多个handle_event回调函数,这些回调函数在事件循环中被执行。IO框架库提供的事件处理器通常是一个接口,用户需要继承它来实现自己的事件处理器,即具体时间处理器,因此,事件处理器中的回调函数一般被声明为虚函数,以支持用户的扩展。此外,事件处理器一般还提供get_handle方法,它返回与该事件处理器关联的句柄。当事件多路分发器检测到有事件发生时,事件多路分发器是通过句柄来通知应用进程的,由于我们将句柄和事件处理器绑定,因此应用才能通过句柄找到正确的事件处理器。
  • 4.Reactor。它是IO框架库的核心,它提供的几个主要方法是:
    • (1)handle_events。该方法执行事件循环,它重复以下过程:等待事件,然后依次处理所有就绪事件对应的事件处理器。

    • (2)register_handler。该方法调用事件多路分发器的register_event方法来往事件多路分发器中注册一个事件。

    • (3)remove_handler。该方法调用事件多路分发器的remove_event方法来删除事件多路分发器中的一个事件。

在这里插入图片描述

(1)事件指的是一个句柄上绑定的事件,如文件描述符0上的可读事件。

(2)事件处理器,也就是event结构体对象,除了包含事件必须具备的两个要素(句柄和事件类型)外,还有其他成员,如回调函数。

(3)事件由事件多路分发器管理,事件处理器则由事件队列管理。事件队列包括多种,如event_base中的注册事件队列、活动事件队列、通用定时器队列,以及evmap中的IO事件队列、信号事件队列。

(4)事件循环对一个被激活事件(就绪事件)的处理,指的是执行该事件对应的事件处理器中的回调函数。

12.2 Libevent源码分析

Libevent的特点:

  • 1.跨平台支持。Libevent支持Linux、UNIX、Windows。

  • 2.统一事件源。Libevent对IO事件、信号、定时事件提供统一的处理。

  • 3.线程安全。Libevent使用libevent_pthreads库来提供线程安全支持。

  • 4.基于Reactor模式实现。

Libevent的官网是http://libevent.org/,其中提供Libevent源码的下载,以及Libevent框架库的第一手文档,且源码和文档的更新也较为频繁。作者写作此书时使用的Libevent版本是2.0.19。

12.2.1 一个实例

使用Libevent的简单实例,实现“Hello World”:

#include <sys/signal.h>
#include <event.h>

void signal_cb(int fd, short event, void *argc) {
    struct event_base *base = (event_base *)argc;
    struct timeval delay = {2, 0};
    printf("Caught an interrupt signal; exiting cleanly in two seconds...\n");
    event_base_loopexit(base, &delay);
}

void timeout_cb(int fd, short event, void *argc) {
    printf("timeout\n");
}

int main() {
    struct event_base *base = event_init();

    struct event *signal_event = evsignal_new(base, SIGINT, signal_cb, base);
    event_add(signal_event, NULL);

    timeval tv = {1, 0};
    struct event *timeout_event = evtimer_new(base, timeout_cb, NULL);
    event_add(timeout_event, &tv);

    event_base_dispatch(base);

    event_free(timeout_event);
    event_free(signal_event);
    event_base_free(base);
}

以上代码虽然简单,但基本描述了Libevent库的主要逻辑:

  • 1.调用event_init函数创建event_base对象。一个event_base对象相当于一个Reactor实例。

  • 2.创建具体的事件处理器,并设置它们所从属的Reactor实例。evsignal_new和evtimer_new分别用于创建信号事件处理器和定时时间处理器,它们是定义在include/event2/event.h文件中的宏:

    • 在这里插入图片描述
    • ​ 可见它们的统一入口是event_new函数,即用于创建通用事件处理器(图12-1中的EventHandler)的函数
  • 3.调用event_add函数,将事件处理器添加到注册事件队列中,并将该事件处理器对应的事件添加到事件多路分发器中。event_add函数相当于Reactor中的register_handler方法。

  • 4.调用event_base_dispatch指定事件循环。

  • 5.时间循环结束后,使用*_free系列函数释放系统资源。

12.2.2 源代码组织结构

略,见P241

12.2.3 event结构体

​ Libevent中的事件处理器是event结构类型,event结构体封装了句柄、事件类型、回调函数、其他必要的标志和数据,该结构体在include/event2/event_struct.h文件中定义:

struct event
{
    TAILQ_ENTRY(event) ev_active_next;
    TAILQ_ENTRY(event) ev_next;
    union {
        TAILQ_ENTRY(event) ev_next_with_common_timeout;
        int min_heap_idx;
    } ev_timeout_pos;
    evutil_socket_t ev_fd;
    struct event_base *ev_base;
    
    union {
        struct {
            TAILQ_ENTRY(event) ev_io_next;
            struct timeval ev_timeout;
        } ev_io;
        
        struct {
            TAILQ_ENTRY(event) ev_signal_next;
            short ev_ncalls;
            short *ev_pncalls;
        } ev_signal;
    } _ev;
    
    short ev_events;
    short ev_res;
    short ev_flags;
    ev_uint8_t ev_pri;
    ev_uint8_t ev_closure;
    struct timeval ev_timeout;
    
    void (*ev_callback)(evutil_socket_t, short, void *arg);
    void *ev_arg;
};
  • ​ 下面介绍event结构体中成员:
    1.ev_events。它代表事件类型,其取值可以是代码清单12-2所示的标志的按位或(互斥的事件类型除外,如读写事件和信号事件不能同时被设置)。

  • 2.ev_next。所有已经注册的事件处理器(包括IO事件处理器和信号事件处理器)通过该成员串联成一个尾队列,我们称之为注册事件队列。

  • 3.ev_active_next。所有被激活的事件处理器通过该成员串联成一个尾队列,我们称之为活动事件队列。活动事件队列不止一个,不同优先级的事件处理器被激活后将被插入不同的活动事件队列中。在事件循环中,Reactor将按优先级从高到低遍历所有活动事件队列,并依次处理其中的事件处理器。

  • 4.ev_timeout_pos。这是一个联合体,它仅用于定时事件处理器,为讨论方便,后面我们称定时时间处理器为定时器,老版本的Libevent中,定时器都是由时间堆来管理的,但开发者认为有时使用简单的链表来管理定时器效率更高,因此,新版本Libevent引入了通用定时器的概念,这些定时器不是存储在时间堆中,而是存储在尾队列中,我们称之为通用定时器队列,对于通用定时器而言,ev_timeout_pos联合体的ev_next_with_common_timeout成员指出了该定时器在通用定时器队列中的位置,对于其他定时器而言,ev_time_pos联合体的min_heap_idx成员指出了该定时器在时间堆中的位置。一个定时器是否是通用定时器取决于其超时值大小,具体判断原则可参考event.c文件中的is_common_timeout函数。

  • 5._ev。这是一个联合体,所有具有相同文件描述符值的IO事件处理器通过ev.ev_io…ev_io_next成员串联成一个尾队列,我们称之为IO事件队列;所有具有相同信号值的信号事件处理器通过ev.ev_signal.ev_signal_next成员串联成一个尾队列,我们称之为信号事件队列,ev.ev_signal.ev_ncalls成员指定信号事件发生时,Reactor需要执行多少次该事件对应的事件处理器中的回调函数,ev.ev_signal.ev_pncalls指针成员要么是NULL,要么指向ev.ev_signal.ev_ncalls。

  • 6.ev_fd。对于IO事件处理器,它是文件描述符值;对于信号事件处理器,它是信号值。

  • 7.ev_base。该事件处理器从属的event_base实例。

  • 8.ev_res。它记录当前激活事件的类型。

  • 9.ev_flags。它是一些事件标志

  • 10.ev_pri。它指定事件处理器的优先级,值越小优先级越高。

  • 11.ev_closure。它指定event_base执行事件处理器的回调函数时的行为

  • 12.ev_timeout。它仅对定时器有效,指定定时器的超时值。

  • 13.ev_callback。它是事件处理器的回调函数,由event_base调用,回调函数被调用时,它的3个参数分别被传入事件处理器的以下3个成员:ev_fd、ev_res、ev_arg。

  • 14.ev_arg。回调函数的参数。

12.2.4 往注册事件队列中添加事件处理器

创建一个event对象的函数是event_new(及其变体),它在event.c文件中实现,该函数很简单,主要给event对象分配内存并初始化它的部分成员。

​ event对象创建好后,应用需要调用event_add函数将其添加到注册事件队列中,并将对应的事件注册到事件多路分发器上。event_add函数在event.c文件中实现,主要是调用另一个内部函数event_add_internal:

// 添加定时器事件时,tv参数一定不为NULL
static inline int event_add_internal(struct event *ev, const struct timeval *tv, 
                                     int tv_is_absolute) {
    struct event_base *base = ev->ev_base;
    int ret = 0;
    int notify = 0;

    // 检查事件循环是否被锁住,如果被锁住,那么当前线程就能安全地执行相关操作
    // 如果事件循环未被锁住,那么这个断言将失败,可能导致程序崩溃
    // 这只是一种调试工具,用于确保在需要时正确地锁定了event_base
    EVENT_BASE_ASSERT_LOCKED(base);
    // _event_debug_assert_is_setup用于断言ev是否已经被设置了
    _event_debug_assert_is_setup(ev);

    // event_debug是一个宏,为了避免运算优先级问题,它的参数使用了括号括起来,因此出现了双层括号
    // 该宏的作用是便于调试,当定义了_EVENT_DEBUG时,它会打印出一条调试信息
    event_debug((
        "event_add: event: %p (fd %d), %s%s%scall %p",
        ev,
        (int)ev->ev_fd,
        ev->ev_events & EV_READ ? "EV_READ " : " ",
        ev->ev_events & EV_WRITE ? "EV_WRITE " : " ",
        tv ? "EV_TIMEOUT " : " ",
        ev->ev_callback));

    // 此处的断言的作用为检查事件的标志是否都在EVLIST_ALL定义的范围内
    EVUTIL_ASSERT(!(ev->ev_flags & ~EVLIST_ALL));

    // 如果新添加的事件处理器尚未被添加到通用定时器队列或时间堆中,则为该定时器在时间堆上预留一个位置
    if (tv != NULL && !(ev->ev_flags & EVLIST_TIMEOUT)) {
        if (min_heap_reserve(&base->timeheap, 1 + min_heap_size(&base->timeheap)) == -1) {
            return -1;
        }
    }

// 如果没有定义此宏,说明没有禁用线程支持
#ifndef _EVENT_DISABLE_THREAD_SUPPORT
    // 如果主线程正在执行该信号处理器的回调函数,且要添加的事件处理器是信号事件处理器
    // 且当前线程不是事件循环所在线程时,则当前调用者必须等待主线程完成调用,否则将引起竞态条件
    // 因为我们要调用event结构体中的回调函数的ev_ncalls次,可能某次调用后就切换了回调函数
    if (base->current_event == ev && (ev->ev_events & EV_SIGNAL) && !EVBASE_IN_THREAD(base)) {
        ++base->current_event_waiters;
        // 使当前线程等待,直到事件处理完成
        EVTHREAD_COND_WAIT(base->current_event_cond, base->th_base_lock);
    }
#endif

    // 如果是读、写、信号事件处理器,且该处理器不在注册事件队列和活动事件队列,则需要将其添加到事件循环
    if ((ev->ev_events & (EV_READ | EV_WRITE | EV_SIGNAL)) 
        && !(ev->ev_flags & (EVLIST_INSERTED | EVLIST_ACTIVE))) {
        // 如果是读、写事件处理器,添加IO事件和IO事件处理器的映射关系
        if (ev->ev_events & (EV_READ | EV_WRITE))
            res = evmap_io_add(base, ev->ev_fd, ev);
        // 如果是信号事件处理器,则添加信号事件和信号事件处理器的映射关系
        } else if (ev->ev_events & EV_SIGNAL) {
            res = evmap_signal_add(base, (int)ev->ev_fd, ev);
        }

        if (res != -1) {
            // 将事件处理器插入注册事件队列
            event_queue_insert(base, ev, EVLIST_INSERTED);
        }
        if (res == 1) {
            // 事件多路分发器中添加了新事件,通知主线程
            notify = 1;
            res = 0;
        }
    }

    // 将事件处理器添加至通用定时器队列或时间堆中,对于信号事件处理器和IO事件处理器
    // 根据evmap_*_add函数的结果决定是否添加(这是为了给事件设置超时)
    // 而对于定时器,则始终应该添加它
    if (res != -1 && tv != NULL) {
        struct timeval now;
        int common_timeout;

        // 对于永久性事件处理器,如果其超时时间不是绝对时间,则将该事件的超时时间记录在ev->ev_io_timeout中
        // ev_io_timeout是定义在event-internal.h文件中的宏:#define ev_io_timeout _ev.ev_io.ev_timeout
        if (ev->ev_closure == EV_CLOSURE_PERSIST && !tv_is_absolute) {
            ev->ev_io_timeout = *tv;
        }

        // 如果该事件处理器已经被插入通用定时器队列或时间堆中,则先删除它
        if (ev->ev_flags & EVLIST_TIMEOUT) {
            // 如果该事件处理器在时间堆顶部,这意味着这个事件是下一个即将超时的事件,此时通知主线程(事件循环)
            if (min_heap_elt_is_top(ev)) {
                notify = 1;
            }
            // 从超时事件列表中移除本事件
            event_queue_remove(base, ev, EVLIST_TIMEOUT);
        }
        
        // 如果待添加的事件处理器已被激活,且激活原因是超时,则从活动事件队列中删除它,以避免其回调函数被执行
        // 对于信号事件处理器,必要时还需将其ncalls成员设为0(ev_pncalls如果不为NULL,则它指向ev_ncalls)
        // 信号事件被触发时,ev_ncalls指定其回调函数被执行的次数,将ev_ncalls置为0,可以干净地终止信号事件的处理
        if ((ev->ev_flags & EVLIST_ACTIVE) && (ev->ev_res & EV_TIMEOUT)) {
            if (ev->ev_events & EV_SIGNAL) {
                if (ev->ev_ncalls && ev->ev_pncalls) {
                    *ev->ev_pncalls = 0;
                }
            }
            
            // 从活动时间队列中删除事件处理器
            event_queue_remove(base, ev, EVLIST_ACTIVE);
        }

        gettime(base, &now);

        common_timeout = is_common_timeout(tv, base);
        if (tv_is_absolute) {
            ev->ev_timeout = *tv;
        // 判断应该将定时器插入通用定时器还是时间堆
        } else if (common_timeout) {
            struct timeval tmp = *tv;
            // 我们只关心毫秒中,MICROSECONDS_MASK中1对应的位
            tmp.tv_usec &= MICROSECONDS_MASK;
            // evutil_timeradd函数将前两个参数的时间相加,存到第三个参数中
            evutil_timeradd(&now, &tmp, &ev->ev_timeout);
            ev->ev_timeout.tv_usec |= (tv->tv_usec & ~MICROSECONDS_MASK);
        } else {
            // 加上当前系统时间,以取得定时器超时的绝对时间
            evutil_timeradd(&now, tv, &ev->ev_timeout);
        }

        event_debug((
            "event_add: timeout in %d seconds, call %p",
            (int)tv->tv_sec, ev->ev_callback));

        // 插入定时器
        event_queue_insert(base, ev, EVLIST_TIMEOUT);

        if (common_timeout) {
            struct common_timeout_list *ctl = get_common_timeout_list(base, &ev->ev_timeout);
            // 如果被插入的事件处理器是通用定时器队列中的第一个元素,则通过调用common_timeout_schedule
            // 将其转移到时间堆中,这样,通用定时器链表和时间堆中的定时器就得到了统一的处理
            if (ev == TAILQ_FIRST(&ctl->events)) {
                common_timeout_schedule(ctl, &now, ev);
            }
        } else {
            if (min_heap_elt_is_top(ev)) {
                notify = 1;
            }
        }
    }

    // 如果有必要,唤醒主线程
    if (res != -1 && notify && EVBASE_NEED_NOTIFY(base)) {
        evthread_notify_base(base);
    }

    // _event_debug_note_add函数用于在调试模式下添加关于事件ev的调试信息记录
    _event_debug_note_add(ev);

    return res;
}

以上函数中调用了几个重要函数:
1.evmap_io_add。该函数将IO事件添加到事件多路分发器中,并将对应事件处理器添加到IO事件队列中,同时建立IO时间和IO事件处理器之间的映射关系。

2.evmap_signal_add。该函数将信号事件添加到事件多路分发器中,并将对应的事件处理器添加到信号事件队列中,同时建立信号事件和信号事件处理器之间的映射关系。

3.event_queue_insert。该函数将事件处理器添加到各种事件队列中:将IO事件处理器和信号事件处理器插入注册事件队列;将定时器插入通用定时器队列或时间堆;将被激活的事件处理器添加到活动事件队列中,其实现如下所示:

static void event_queue_insert(struct event_base *base, struct event *ev, int queue) {
    EVENT_BASE_ASSERT_LOCKED(base);
    // 避免重复插入
    if (ev->ev_flags & queue) {
        // Double insertion is possible for active events
        if (queue & EVLIST_ACTIVE) {
            return;
        }
        
        // event_errx函数生成一个错误消息并将其打印到标准错误
        // __func__是一个预定义的标识符,用于在函数内部获取函数名
        event_errx(1, "%s: %p(fd %d) already on queue %x", __func__, ev, ev->ev_fd, queue);
        return;
    }

    // 如果不是内部事件,则增加event_base拥有的事件处理器总数
    if (~ev->ev_flags & EVLIST_INTERNAL) {
        ++base->event_count;
    }

    // 标记此事件已被添加过
    ev->ev_flags |= queue;
    
    switch (queue) {
    case EVLIST_INSERTED:
        // 将IO事件处理器或信号事件处理器插入注册事件队列
        TAILQ_INSERT_TAIL(&base->eventqueue, ev, ev_next);
        break;

    case EVLIST_ACTIVE:
        ++base->event_count_active;
        // 将就绪事件处理器插入活动事件队列
        TAILQ_INSERT_TAIL(&base->activequeues[ev->ev_pri], ev, ev_active_next);
        break;

    case EVLIST_TIMEOUT:
        // 将定时器插入通用定时器队列或时间堆
        if (is_common_timeout(&ev->ev_timeout, base)) {
            struct common_timeout_list *ctl = get_common_timeout_list(base, &ev->ev_timeout);
            insert_common_timeout_inorder(ctl, ev);
        } else {
            min_heap_push(&base->timeheap, ev);
        }
        break;

    default:
        event_errx(1, "%s: unknown queue %x", __func__, queue);
    }
}
12.2.5 往事件多路分发器中注册事件

​ 以上event_queue_insert函数所做的仅仅是将一个事件处理器加入event_base的某个事件队列中,对于新添加的IO事件处理器和信号事件处理器,我们还需让事件多路分发器来监听其对应的事件,同时建立文件描述符、信号值、事件处理器之间的映射关系,这需要通过evmap_io_add和evmap_signal_add函数来完成,这两个函数相当于事件多路分发器中的register_event方法,它们由evmap.c文件实现。

​ 其余见P248

12.2.6 eventop结构体

​ eventop结构体封装了IO复用机制必要的一些操作,如注册事件、等待事件。它为event_base支持的所有后端IO复用机制提供了一个统一的接口,该结构体定义在event-internal.h文件中

​ 见P251

​ Libevent通过遍历eventops数组来选择其后端IO复用技术,数组中首个元素优先级最高,最后一个元素最低,所以,在Linux下,Libevent默认选择的后端IO复用技术是epoll,但我们可以修改以上代码来选择不同的后端IO复用技术。

12.2.7 event_base结构体

​ 结构体event_base是Libevent的Reactor,它定义在event-internal.h文件中

​ 见P253

12.2.8 事件循环

​ Libevent的动力,即事件循环。Libevent中实现事件循环的函数是event_base_loop,该函数首先调用IO事件多路分发器的事件监听函数,以等待事件,当有事件发生时,就依次处理之:

​ 见P254

第13章 多进程编程

​ 我们将讨论Linux多进程编程的以下内容:

  • 1.复制进程映像的fork系统调用和替换进程映像的exec系列系统调用。

  • 2.僵尸进程以及如何避免僵尸进程。

  • 3.进程间通信(Inter Process Communication,IPC)最简单的方式:管道。

  • 4.三种System V进程间通信方式:信号量、消息队列、共享内存。它们是由AT&T System V2版本的UNIX引入的,所以统称为System V IPC。

  • 5.在进程间传递文件描述符的通用方法:通过UNIX本地域socket传递特殊的辅助数据。

13.1 fork系统调用

​ Linux下创建新进程的系统调用是fork:

#include <sys/types.h>
#include <unistd.h>

// 返回两次,父进程返回的子进程的 PID,子进程中返回 0,失败 -1
// 该返回值是后续代码判断当前进程是父进程还是子进程的依据
pid_t fork(void );

​ fork函数复制当前进程,在内核进程表中创建一个新的进程表项,新的进程表项中很多属性和原进程相同,如堆指针、栈指针、标志寄存器的值,但也有很多属性被赋予了新值,如子进程的PPID被设置成原进程的PID,信号位图被清除(原进程设置的信号处理函数不再对新进程起作用)。

子进程的代码和父进程完全相同,同时它还会复制父进程的数据(堆数据、栈数据、静态数据),数据的复制采用的是写时复制(copy on write),即只有在任一进程(父进程或子进程)对数据执行了写操作时,复制才会发生(先是缺页中断,然后操作系统给子进程分配内存并复制父进程的数据),即便如此,如果我们在程序中分配了大量内存,那么使用fork函数时也应当谨慎,尽量避免没必要的内存分配和数据复制。

​ 此外,创建子进程后,父进程中打开的文件描述符默认在子进程中也是打开的,且文件描述符的引用计数加1。

13.2 exec系列系统调用

需要在子进程种执行其他程序,即替换当前进程映像,就需要使用 exec 系列函数

#include <unistd.h>
extern char** environ;

// path 参数指定可执行文件的完整路径
// file 参数接受文件名
// avg 接受可变参数,argv 接受参数数组,它们都会被传递给新程序(path或file参数指定的程序)的main函数
// envp 设置新程序的环境变量,如果未设置它,则新程序将使用由全局变量environ指定的环境变量。
int execl(const char* path, const char* arg, ...);
int execlp(const char* file, const char* arg, ...);
int execle(const char* path, const char* arg, ..., char* const envp[]);

int execv(const char* path, const char* argv[] );
int execvp(const char* file, const char* argv[] );
int execvpe(const char* path, const char* argv[], char* const envp[]);

​ 一般,exec函数是不返回的,除非出错,此时它返回-1,并设置errno。如果没出错,则原进程中exec调用之后的代码都不会执行,因为此时原程序已经被exec的参数指定的程序完全替换(包括代码和数据)

​ exec函数不会关闭原进程打开的文件描述符,除非该文件描述符被设置了SOCK_CLOEXEC属性。

13.3 处理僵尸进程

​ 对多进程程序而言,父进程一般需要跟踪子进程的退出状态,因此,当子进程结束运行时,内核不会立即释放该进程的进程表表项,以满足父进程后续对该子进程退出信息的查询(如果父进程还在运行)

在子进程结束运行后,父进程读取其退出状态前,我们称该子进程处于僵尸态。

​ 父进程终止,而子进程继续运行时,子进程的PPID将被操作系统设置为1,即init进程,init进程接管了该子进程,并等待它结束。在父进程退出之后,子进程退出之前,该子进程处于僵尸态。

如果父进程没有正确处理子进程的返回信息,子进程将停留在僵尸态,并占据着内核资源,这是不能容许的,因为内核资源有限,以下函数在父进程中调用,以等待子进程的结束,并获取子进程的返回信息,从而避免了僵尸进程的产生,或使子进程从僵尸态结束:

#include <sys/types.h>
#include <sys/wait.h>

// wait函数将阻塞进程,直到该进程的某个子进程结束运行,它返回结束运行的子进程的PID,并将该子进程的退出状态信息存储于stat_loc参数指向的内存中
pid_t wait(int* stat_loc);

// wait函数是阻塞的,而waitpid函数解决了这个问题。
// 只等待 pid 指定的子进程,为 -1 时与 wait 一样,即等待任意一个子进程结束
// options 指定 WNOHANG 参数可以非阻塞:如果 pid 子进程还未结束或者意外终止直接返回 0;如果目标子进程确实正常退出了,则waitpid函数返回该子进程的PID
pid_t waitpid(pid_t pid, int* stat_loc, int options);

​ 当一个进程结束时,它将给父进程发送一个SIGCHLD信号,我们可以在父进程中捕获SIGCHLD信号,并在信号处理函数中调用waitpid函数以“彻底结束”一个子进程:

static void handle_child(int sig) {
    pid_t pid;
    int stat;
    while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) {
        // 对结束的子进程进行善后处理
    }
}

13.4 管道

​ 管道也是父子进程间通信的常用手段

​ 管道能在父子进程间传递数据,利用的是调用fork后两个管道文件描述符都保持打开,一对这样的文件描述符能保证父子进程间一个方向的数据传输,父进程和子进程必须有一个关闭fd[0],另一个关闭fd[1],下图使用管道实现从父进程向子进程写数据:

在这里插入图片描述

​ 如果要实现父子进程之间的双向数据传输,可以使用两个管道

管道只能用于有关联的两个进程(如父子进程)间的通信,而以下要讨论的三种System V IPV能用于无关联的多个进程之间的通信,因为它们都使用一个全局唯一的键值来标识一条信道。有一种特殊的管道称为FIFO(First In First Out,先进先出),也叫命名管道,它也能用于无关联进程之间的通信,但FIFO管道在网络编程中用得不多

13.5 信号量

13.5.1 信号量原语

​ 当多个进程同时访问系统上某个资源时,如同时写一个数据库的某条记录,或同时修改某个文件,就需要考虑进程同步问题,以确保任一时刻只有一个进程可以拥有对资源的独占式访问。通常,进程对共享资源的访问的代码只是很短的一段,但这段代码引发了进程之间的竞态条件,我们称这段代码为关键代码区,或临界区,对进程同步,就是确保任一时刻只有一个进程能进入关键代码段。

信号量(Semaphore)是一种特殊的变量,它只能取自然数值且只支持两种操作:等待(wait)和信号(signal)。但在Linux/UNIX中,等待和信号都已经具有特殊含义,所以对信号量的这两种操作更常用的称呼是P(传递,就好像进入临界区)、V(释放,就好像退出临界区)操作

​ 假设有信号量SV,对它的P、V操作含义如下:

  • 1.P(SV),如果SV的值大于0,就将它减1,如果SV的值为0,则挂起进程的执行。
  • 2.V(SV),如果有其他进程因为等待SV而挂起,则唤醒之,如果没有,则将SV加1。

​ 信号量的取值可以是任何自然数,但最常用的、最简单的信号量是二进制信号量,它只能取0或1两个值,我们仅讨论二进制信号量。使用二进制信号量同步两个进程,以确保关键代码段的独占式访问的例子:

在这里插入图片描述

​ Linux信号量的API定义在sys/sem.h头文件中,主要包括3个系统调用:semget、semop、semctl。它们被设计为操作一组信号量,即信号量集,而不是单个信号量

13.5.2 semget系统调用

​ semget系统调用创建一个新的信号量集,或获取一个已经存在的信号量集,其定义如下:

#include <sys/sem.h>

// key 参数标识一个全局唯一的信号量集合,要通过信号量通信的进程需要使用相同的键值来创建/获取该信号量。
// num_sems 指定要创建/获取的信号量集中信号量的数目,如果是创建信号量,则该值必须被指定;如果是获取已存在的信号量,则可把它设为0。
// sem_flags 参数指定一组标志,它低端的9个比特是该信号量的权限
// [return] 成功返回一个正整数表示信号量集的标识符,失败 -1 并设置 errno
int semget(ket_t key, int num_sems, int sem_flags);

​ 如果semget函数用于创建信号量集,则与之关联的内核数据结构体semid_ds将被创建并初始化:

在这里插入图片描述

13.5.3 semop系统调用

​ semop系统调用改变信号量的值,即执行P、V操作。

​ 在讨论semop函数前,先介绍与每个信号量关联的一些重要的内核变量:

在这里插入图片描述

​ semop函数对信号量的操作实际就是改变上图中内核变量的操作,该函数定义如下:

#include <sys/sem.h>

// sem_id 是 semget 返回的信号量集标识符,用以指定被操作的目标信号量集
// sem_ops 指向一个 sembuf 结构体类型的数组
// num_sem_ops 指定要执行的操作个数
int semop(int sem_id, struct sembuf* sem_ops, size_t num_sem_ops);

​ sembuf结构体类型:

在这里插入图片描述

其中,

  • sem_num成员是信号集中信号量的编号,0表示信号量集中的第一个信号量。
  • sem_op成员指定操作类型,其可选值为正整数、0、负整数,操作的行为会受到sem_flg成员的影响。
  • sem_flg成员的可选值:
    • 1.IPC_NOWAIT:无论信号量操作是否成功,semop调用都立即返回,这类似于非阻塞IO操作。
    • 2.SEM_UNDO:当进程退出时,取消正在进行的semop操作。

sem_op成员和sem_flg成员按以下方式影响semop函数的行为:

  • 1.如果sem_op成员大于0,则semop将被操作的信号量的值semval增加sem_op成员值。该操作要求调用进程对被操作信号量集拥有写权限。此时若设置了SEM_UNDO标志,则系统将更新进程的semadj变量(用来跟踪进程对信号量的修改)。

  • 2.如果sem_op成员等于0,则表示这是一个等待0(wait-for-zero)操作,该操作要求调用进程对被操作信号量集拥有读权限。如果此时信号量的值是0,则调用立即成功返回;如果信号量的值非0,则semop函数失败返回(当IPC_NOWAIT标志被指定)并将errno设为EAGAIN,或阻塞进程(没有指定IPC_NOWAIT标志)以等待信号量变为0。如果未指定IPC_NOWAIT标志,则信号量的semzcnt值加1,进程被投入睡眠,直到以下三个条件之一发生:

    • (1)信号量的值semval变为0,此时系统将该信号量的semzcnt值减1。

    • (2)被操作信号量所在的信号量集被进程移除,此时semop函数失败返回,errno被设为EIDRM。

    • (3)调用被信号中断,此时semop函数失败返回,errno被设为EINTR,同时系统将该信号量的semzcnt值减1。

  • 3.如果sem_op成员小于0,表示对信号量值进行减操作,即期望获得信号量。该操作要求调用进程对被操作信号量拥有写权限。如果信号量的值semval大于等于sem_op成员的绝对值,则semop函数操作成功,调用进程立即获得信号量,且系统将该信号量的值semval减去sem_op成员的绝对值。如果设置了SEM_UNDO标志,则系统将更新进程的semadj变量。如果信号量的值semval小于sem_op成员的绝对值,则semop函数失败返回或阻塞进程以等待信号量可用,此时,当IPC_NOWAIT标志被指定时,semop函数立即返回一个错误,并设置errno为EAGAIN,如果未指定IPC_NOWAIT标志,则信号量的semncnt值加1,进程被投入睡眠直到以下三个条件之一发生:

    • (1)信号量的值semncnt变得大于等于sem_op成员额绝对值,此时系统将该信号量的semncnt值减1,并将semval减去sem_op成员的绝对值,同时,如果SEM_UNDO标志被设置,则系统更新semadj变量。

    • (2)被操作信号量所在的信号量集被进程移除,此时semop函数失败返回,errno被设为EIDRM。

    • (3)函数被信号终端,此时semop函数失败返回,errno被置为EINTR,同时系统将该信号量的semncnt值减1。

13.5.4 semctl系统调用

​ semctl系统调用允许调用者对信号量进行直接控制:

#include <sys/sem.h>

// sem_id 是 semget 返回的信号量集标识符
// sem_num 指定被操作的信号量在信号量集中的编号
// command 指定要执行的命令
// 有的命令需要调用者传递第4个参数,第4个参数的类型由操作类型决定,可能是一个整数或一个指向semun联合的指针
int semctl(int sem_id, int sem_num, int command, ...);
13.5.5 特殊键值IPC_PRIVATE

​ semget的调用者可以给其key参数传递一个特殊键值IPC_PRIVATE(其值为0),这样无论该信号量是否已存在,semget函数都将创建一个新信号量,使用该键值创建的信号量并非像它的名字声称的那样是进程私有的,其他进程,尤其是子进程,也有方法来访问这个信号量,所以semget函数的man手册的BUGS部分上说,使用名字IPC_PRIVATE有些误导(历史原因),应称为IPC_NEW。以下代码就在父子进程间使用一个IPC_PRIVATE信号量来同步:

#include <sys/sem.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

union semun {
    int val;
    struct semid_ds *buf;
    unsigned short int *array;
    struct seminfo *__buf;
};

// op参数为-1时执行P操作,为1时执行V操作
void pv(int sem_id, int op) {
    struct sembuf sem_b;
    sem_b.sem_num = 0;
    sem_b.sem_op = op;
    sem_b.sem_flg = SEM_UNDO;
    semop(sem_id, &sem_b, 1);
}

int main(int argc, char *argv[]) {
    int sem_id = semget(IPC_PRIVATE, 1, 0666);

    union semun sem_un;
    sem_un.val = 1;
    semctl(sem_id, 0, SETVAL, sem_un);

    pid_t id = fork();
    if (id < 0) {
        return 1;
    } else if (id == 0) {
        printf("child try to get binary sem\n");

        // 在父子进程间共享IPC_PRIVATE信号量的关键在于两者都可以操作该信号量的标识符sem_id
        pv(sem_id, -1);
        printf("child get the sem and would release it after 5 seconds\n");
        sleep(5);
        pv(sem_id, 1);
        exit(0);
    } else {
        printf("parent try to get binary sem\n");
        pv(sem_id, -1);
        printf("parent get the sem and would release it after 5 seconds\n");
        sleep(5);
        pv(sem_id, 1);
    }

    waitpid(id, NULL, 0);
    semctl(sem_id, 0, IPC_RMID, sem_un);
    return 0;
}

13.6 共享内存

共享内存是最高效的IPC机制,因为它不涉及进程之间的任何数据传输,这种高效率带来的问题是,我们必须用其他辅助手段来同步进程对共享内存的访问,否则会产生竞态条件,因此,共享内存通常和其他进程间通信方式一起使用

13.6.1 shmget系统调用

​ shmget系统调用创建一段新的共享内存,或获取一段已经存在的共享内存,其定义为:

#include <sys/shm.h>

// key 表示一段全局唯一的共享内存
// size 指定共享内存的大小,单位 byte,如果是创建新的共享内存,则size参数必须被指定,如果是获取已经存在的共享内存,则可以把size参数设为0
// shmflg 和 sem_flags 类似
// [return] 成功返回一个正整数标识共享内存的标识符,失败 -1 并设置 errno
int shmget(key_t key, size_t size, int shmflg);

​ shmflg参数的使用和含义与semget系统调用的sem_flags参数相同,但shmget函数支持两个额外的标志:

  • 1.SHM_HUGETLB:类似mmap函数的MAP_HUGETLB标志,系统将使用大页面来为共享内存分配空间。大页是一种更大的物理内存页面,相对于传统的小页(通常是4KB),大页的大小通常为2MB或更大,使用大页可以提高内存访问的性能,特别适用于需要大量内存的应用程序,如数据库和科学计算。
  • 2.SHM_NORESERVE:类似于mmap函数的MAP_NORESERVE标志,不为共享内存保留交换分区(swap空间),这样,当物理内存不足时,对该共享内存执行写操作将触发SIGSEGV信号。

​ 如果shmget函数用于创建共享内存,则这段共享内存的所有字节都被初始化为0,与之关联的内核数据结构shmid_ds将被创建并初始化:

在这里插入图片描述

在这里插入图片描述

13.6.2 shmat和shmdt系统调用

​ 共享内存被创建/获取后,我们不能立即访问它,而是需要先将它关联到进程的地址空间中,使用完共享内存后,我们也需要将它从进程地址空间中分离,这两项任务分别由以下两个系统调用实现:

// shm_id 是由 shmget 返回的共享内存标识符
// shm_addr 指定共享内存关联到进程的那块地址空间
// shmflg 是一些标志 SHM_RND|SHM_RDONLY...
// [return] 成功时返回共享内存被关联到的地址,失败返回 -1 并设置 errno
void* shmat(int shm_id, const void* shm_addr, int shmflg);

// 将关联到的 shm_addr 处的共享内存从进程中分离,失败 -1 并设置 errno
int shmdt(const void* shm_addr);

13.6.3 shmctl系统调用

​ shmctl系统调用控制共享内存的某些属性:

#include <sys/shm.d>

// shm_id 是 shmget 返回的共享内存标识符
// command 指定要执行的命令
// [return] 成功返回值取决于 command,失败 -1 并设置 errno
int shmctl(int shm_id, int command, struct shmid_ds* buf);
13.6.4 共享内存的POSIX方法

​ mmap 函数利用它的 MAP ANONYMOUS 标志我们可以实现父、子进程之间的匿名内存共享。通过打开同一个文件,mmap 也可以实现无关进程之间的内存共享。Linux 提供了另外一种利用mmap 在无关进程之间共享内存的方式。这种方式无需任何文件的支特,但它需要先使用如下函数来创建或打开一个 POSIX 共享内存对象:

#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>

// 与 open 系统调用完全相同,shm_open 成功时返回一个文件描述符,失败 -1 并设置 errno
int shm_open(const char* name, int oflag, mode_t mode);

// shm_open 创建的共享内存对象使用完之后需要删除
int shm_unlink(const char* name);

​ 如果代码中使用了以上POSIX共享内存函数,则编译时需要指定链接选项-lrt

13.6.5 共享内存实例

​ 将第九章中的聊天室服务器程序改为一个多进程服务器,一个子进程处理一个客户连接,同时,将所有客户socket连接的读缓冲设计为一块共享内存:

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <libgen.h>

#define USER_LIMIT 5
#define BUFFER_SIZE 1024
#define FD_LIMIT 65535
#define MAX_EVENT_NUMBER 1024
#define PROCESS_LIMIT 65536

// 处理一个客户连接所必要的数据
struct client_data {
    // 客户的socket地址
    sockaddr_in address;
    // socket文件描述符
    int connfd;
    // 处理这个连接的子进程PID
    pid_t pid;
    // 和父进程通信用的管道
    int pipefd[2];
};

static const char *shm_name = "/my_shm";
int sig_pipefd[2];
int epollfd;
int listenfd;
int shmfd;
char *share_mem = 0;
// 客户连接数组,进程用客户连接的编号来索引这个数组,即可取得相关客户的连接数据
client_data *users = 0;
// 子进程和客户连接的映射关系表,用进程的PID来索引这个数组,即可取得该进程处理的客户连接的编号
int *sub_process = 0;
// 当前客户数量
int user_count = 0;
bool stop_child = false;

int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}

void addfd(int epollfd, int fd) {
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET;
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
    setnonblocking(fd);
}

void sig_handler(int sig) {
    int save_errno = errno;
    int msg = sig;
    // bug,如果主机字节序是大端字节序,则传递的值为0
    send(sig_pipefd[1], (char *)&msg, 1, 0);
    errno = save_errno;
}

void addsig(int sig, void (*handler)(int), bool restart = true) {
    struct sigaction sa;
    memset(&sa, '\0', sizeof(sa));
    sa.sa_handler = handler;
    if (restart) {
        sa.sa_flags |= SA_RESTART;
    }
    sigfillset(&sa.sa_mask);
    assert(sigaction(sig, &sa, NULL) != -1);
}

void del_resource() {
    close(sig_pipefd[0]);
    close(sig_pipefd[1]);
    close(listenfd);
    close(epollfd);
    shm_unlink(shm_name);
    delete[] users;
    delete[] sub_process;
}

// 停止一个子进程
void child_term_handler(int sig) {
    stop_child = true;
}

// 子进程运行的函数,参数idx指出该子进程处理的客户连接的编号,users参数是保存所有客户连接数据的数组
// share_mem参数指出共享内存的起始地址
int run_child(int idx, client_data *users, char *share_mem) {
    epoll_event events[MAX_EVENT_NUMBER];
    // 子进程使用IO复用同时监听两个文件描述符:客户连接socket、与父进程通信的管道文件描述符
    int child_epollfd = epoll_create(5);
    assert(child_epollfd != -1);
    int connfd = users[idx].connfd;
    addfd(child_epollfd, connfd);
    int pipefd = users[idx].pipefd[1];
    addfd(child_epollfd, pipefd);
    int ret;
    // 设置子进程的SIGTERM信号的处理函数
    addsig(SIGTERM, child_term_handler, false);

    while (!stop_child) {
        int number = epoll_wait(child_epollfd, events, MAX_EVENT_NUMBER, -1);
        if ((number < 0) && (errno != EINTR)) {
            printf("epoll failure\n");
            break;
        }

        for (int i = 0; i < number; ++i) {
            int sockfd = events[i].data.fd;
            // 本子进程负责的客户连接有数据到达
            if ((sockfd == connfd) && (events[i].events & EPOLLIN)) {
                // 作者在此处每次读前初始化要读到的目标内存,效率较低
                // 每次读完数据后,在读到的数据最后加一个空字符可能是个更好的方法
                memset(share_mem + idx * BUFFER_SIZE, '\0', BUFFER_SIZE);
                // 将客户数据读取到共享内存中的一段空间中,这段空间仅由本进程写
                ret = recv(connfd, share_mem + idx * BUFFER_SIZE, BUFFER_SIZE - 1, 0);
                if (ret < 0) {
                    if (errno != EAGAIN) {
                        stop_child = true;
                    }
                } else if (ret == 0) {
                    stop_child = true;
                } else {
                    // 成功读取客户数据后通过管道通知主进程来处理,将发送消息的客户连接的编号发给主进程
                    send(pipefd, (char *)&idx, sizeof(idx), 0);
                }
                // 主进程通过管道通知本进程将第client个客户(其他客户)的数据发送到本进程负责的客户端
                // 即第client个客户发了消息,要将该客户发的消息发给其他客户
            } else if ((sockfd == pipefd) && (events[i].events & EPOLLIN)) {
                int client = 0;
                // 接收主进程通过管道发来的发送消息的客户的编号,即client
                ret = recv(sockfd, (char *)&client, sizeof(client), 0);
                if (ret < 0) {
                    if (errno != EAGAIN) {
                        stop_child = true;
                    }
                } else if (ret == 0) {
                    stop_child = true;
                } else {
                    send(connfd, share_mem + client * BUFFER_SIZE, BUFFER_SIZE, 0);
                }
            } else {
                continue;
            }
        }
    }

    close(connfd);
    close(pipefd);
    close(child_epollfd);
    return 0;
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);

    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(listenfd, 5);
    assert(ret != -1);

    user_count = 0;
    users = new client_data[USER_LIMIT + 1];
    sub_process = new int[PROCESS_LIMIT];
    for (int i = 0; i < PROCESS_LIMIT; ++i) {
        sub_process[i] = -1;
    }

    epoll_event events[MAX_EVENT_NUMBER];
    epollfd = epoll_create(5);
    assert(epollfd != -1);
    addfd(epollfd, listenfd);

    ret = socketpair(PF_UNIX, SOCK_STREAM, 0, sig_pipefd);
    assert(ret != -1);
    setnonblocking(sig_pipefd[1]);
    addfd(epollfd, sig_pipefd[0]);

    addsig(SIGCHLD, sig_handler);
    addsig(SIGTERM, sig_handler);
    addsig(SIGINT, sig_handler);
    addsig(SIGPIPE, SIG_IGN);
    bool stop_server = false;
    bool terminate = false;

    // 创建共享内存
    shmfd = shm_open(shm_name, O_CREAT | O_RDWR, 0666);
    assert(shmfd != -1);
    // ftruncate是用于修改文件大小的系统调用函数,也可用于修改共享内存大小
    ret = ftruncate(shmfd, USER_LIMIT * BUFFER_SIZE);
    assert(ret != -1);

    // mmap函数的第1个参数为NULL,表示让内核自动选择映射地址
    // 第4个参数是MAP_SHARED,表示共享内存可以被多个进程共享
    // 第5个参数指定要映射的文件或共享内存对象
    // 第6个参数指定要映射的文件或共享内存的起始位置
    share_mem = (char *)mmap(NULL, USER_LIMIT * BUFFER_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED,
                             shmfd, 0);
    assert(share_mem != MAP_FAILED);
    close(shmfd);

    while (!stop_server) {
        int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
        if ((number < 0) && (errno != EINTR)) {
            printf("epoll failure\n");
            break;
        }

        for (int i = 0; i < number; ++i) {
            int sockfd = events[i].data.fd;
            // 新的客户连接到来
            if (sockfd == listenfd) {
                struct sockaddr_in client_address;
                socklen_t client_addrlength = sizeof(client_address);
                int connfd = accept(listenfd, (struct sockaddr *)&client_address,
                                    &client_addrlength);
                if (connfd < 0) {
                    printf("errno is: %s\n", errno);
                    continue;
                }
                if (user_count >= USER_LIMIT) {
                    const char *info = "too many users\n";
                    printf("%s", info);
                    send(connfd, info, strlen(info), 0);
                    close(connfd);
                    continue;
                }
                // 保存第user_count个客户连接的相关数据
                users[user_count].address = client_address;
                users[user_count].connfd = connfd;
                // socketpair函数的第3个协议参数为0表示将使用适合于第二个套接字类型参数的默认协议
                // 在主进程和子进程之间建立管道,以传递必要的数据
                ret = socketpair(PF_UNIX, SOCK_STREAM, 0, users[user_count].pipefd);
                assert(ret != -1);
                pid_t pid = fork();
                if (pid < 0) {
                    // 此处子进程分配失败,关闭了已连接描述符
                    // 但socketpair创建的管道描述符仍未关闭,下次客户连接会创建新的管道
                    // 可能导致描述符达到进程能处理的上限
                    close(connfd);
                    continue;
                } else if (pid == 0) {
                    close(epollfd);
                    close(listenfd);
                    close(users[user_count].pipefd[0]);
                    close(sig_pipefd[0]);
                    close(sig_pipefd[1]);
                    run_child(user_count, users, share_mem);
                    // 只有所有进程都调用munmap将共享内存从进程分离后,系统才销毁这个共享内存对象所占据的资源
                    munmap((void *)share_mem, USER_LIMIT * BUFFER_SIZE);
                    exit(0);
                } else {
                    close(connfd);
                    close(users[user_count].pipefd[1]);
                    addfd(epollfd, users[user_count].pipefd[0]);
                    users[user_count].pid = pid;
                    // 记录新的客户连接在数组users中的索引值(即客户连接的编号)
                    // 此处是有问题的,pid_t的类型通常是int,而sub_process数组大小只有65536
                    // 此处sub_process用unordered_map类型可能是更好的选择
                    sub_process[pid] = user_count;
                    ++user_count;
                }
                // 处理信号事件
            } else if ((sockfd == sig_pipefd[0]) && (events[i].events & EPOLLIN)) {
                int sig;
                char signals[1024];
                ret = recv(sig_pipefd[0], signals, sizeof(signals), 0);
                if (ret == -1) {
                    continue;
                } else if (ret == 0) {
                    continue;
                } else {
                    for (int i = 0; i < ret; ++i) {
                        switch (signals[i]) {
                            // 子进程退出,某个客户关闭了连接
                            case SIGCHLD:
                                pid_t pid;
                                int stat;
                                while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) {
                                    // 用子进程的pid取得被关闭的客户连接的编号
                                    int del_user = sub_process[pid];
                                    sub_process[pid] = -1;
                                    if (del_user < 0 || del_user > USER_LIMIT) {
                                        continue;
                                    }
                                    // 清除第del_user个客户连接的相关数据
                                    epoll_ctl(epollfd, EPOLL_CTL_DEL, users[del_user].pipefd[0], 0);
                                    close(users[del_user].pipefd[0]);
                                    // 此处有个bug,作者是想把最大的客户连接的编号改为刚关闭连接的编号
                                    // 如果此时编号最大的客户连接有数据到达
                                    // 实际读到的数据还是存在原编号最大的连接对应的内存
                                    // 然后让主线程读原编号最大的连接对应的内存
                                    // 如果后面有了新连接,占用了原来最大的编号
                                    // 则会出现两个线程同时写同一块内存的情形
                                    users[del_user] = users[--user_count];
                                    sub_process[users[del_user].pid] = del_user;
                                }
                                if (terminate && user_count == 0) {
                                    stop_server = true;
                                }
                                break;

                            case SIGTERM:
                            case SIGINT:
                                // 结束服务器程序
                                printf("kill all the child now\n");
                                if (user_count == 0) {
                                    stop_server = true;
                                    break;
                                }
                                for (int i = 0; i < user_count; ++i) {
                                    int pid = users[i].pid;
                                    kill(pid, SIGTERM);
                                }
                                terminate = true;
                                break;

                            default:
                                break;
                        }
                    }
                }
                // 某个子进程向父进程写入了数据
            } else if (events[i].events & EPOLLIN) {
                int child = 0;
                // 读取管道数据,child变量记录了发送消息的客户的连接编号
                ret = recv(sockfd, (char *)&child, sizeof(child), 0);
                printf("read data from child across pipe\n");
                if (ret == -1) {
                    continue;
                } else if (ret == 0) {
                    continue;
                } else {
                    // 向除了发送客户外的其他所有客户的子进程发消息,通知它们有客户数据要写
                    for (int j = 0; j < user_count; ++j) {
                        if (users[j].pipefd[0] != sockfd) {
                            printf("send data to child across pipe\n");
                            send(users[j].pipefd[0], (char *)&child, sizeof(child), 0);
                        }
                    }
                }
            }
        }
    }

    del_resource();
    return 0;
}

​ 上面代码有两点需要注意:

  • 1.虽然我们使用了共享内存,但每个子进程都只会往自己所处理的客户连接所对应的那部分内存中写数据,所以我们使用共享内存的目的只是为了共享读,因此,每个子进程在使用共享内存时都无须加锁。
  • 2.我们的服务器启动时给users数组分配了足够多的空间,使得它可以存储所有可能的客户连接的相关数据,同样,我们给sub_process数组分配的空间也足以存储所有可能的子进程的相关数据,这是牺牲空间换取时间的例子。

13.7 消息队列

​ 消息队列是在两个进程间传递二进制数据块的方式,每个数据块都有一个特定类型,接收方可以根据类型来有选择地接收数据,而不一定像管道和命名管道那样必须以先进先出的方式接收数据。

同样的有 4 个系统调用:

#include <sys/msg.h>

// 创建一个消息队列,或者获取一个已有的消息队列
int msgget(key_t key, int msgflg);

// 将一条消息 msg_ptr 添加到消息队列
int msgsnd(int msqid, const void* msg_ptr, size_t msg_sz, int msgflg);

// 从消息队列中获取消息 msg_ptr
int msgrcv(int msqid, void* msg_ptr, size_t msg_sz, long int msgtype, int msgflg);

// 控制消息队列的某些属性
int msgctl(int msqid, int command, struct msqid_ds* buf);

13.8 IPC命令

​ ipcs 命令可以显示 Linux 系统拥有的共享内存、信号量和消息队列资源使用情况

在这里插入图片描述

​ 上图中的输出结果分段显示了系统拥有的共享内存、信号量、消息队列资源,可见,该系统目前尚未使用任何共享内存和消息队列,但分配了一组键值为0(IPC_PRIVATE)的信号量,这些信号量的所有者是apache,因此它们是由httpd服务器进程创建的。标识符为393222的信号量正是上面我们讨论httpd各个子进程间同步epoll_wait函数使用权的信号量。

​ 我们可用ipcrm命令删除遗留在系统中的共享资源。

13.9 在进程间传递文件描述符

​ 传递一个文件描述符并不是传递一个文件描述符的值,而是要在接收进程中创建一个新的文件描述符,并且该文件描述符和发送进程中被传递的文件描述符指向内核中相同的文件表项。

​ 在 Linux 下,我们可以利用 UNIX 域 socket 在进程间传递特殊的辅助数据,以实现文件描述符的传递。代码清单 13-5 给出了一个实例,它在子进程中打开一个文件描述符,然后将它传递给父进程,父进程则通过读取该文件描述符来获得文件的内容。

#include <sys/socket.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>

static const int CONTROL_LEN = CMSG_LEN(sizeof(int));
// 发送文件描述符,fd参数是用来传递信息的UNIX域socket,fd_to_send参数是待发送的文件描述符
void send_fd(int fd, int fd_to_send) {
    struct iovec iov[1];
    struct msghdr msg;
    // 没有数据要发送
    char buf[0];

    iov[0].iov_base = buf;
    iov[0].iov_len = 1;
    msg.msg_name = NULL;
    msg.msg_namelen = 0;
    msg.msg_iov = iov;
    msg.msg_iovlen = 1;

    cmsghdr cm;
    cm.cmsg_len = CONTROL_LEN;
    cm.cmsg_level = SOL_SOCKET;
    cm.cmsg_type = SCM_RIGHTS;
    *(int *)CMSG_DATA(&cm) = fd_to_send;
    // 设置辅助数据
    msg.msg_control = &cm;
    msg.msg_controllen = CONTROL_LEN;

    sendmsg(fd, &msg, 0);
}

// 接收目标文件描述符
int recv_fd(int fd) {
    struct iovec iov[1];
    struct msghdr msg;
    char buf[0];

    iov[0].iov_base = buf;
    iov[0].iov_len = 1;
    msg.msg_name = NULL;
    msg.msg_namelen = 0;
    msg.msg_iov = iov;
    msg.msg_iovlen = 1;

    cmsghdr cm;
    msg.msg_control = &cm;
    msg.msg_controllen = CONTROL_LEN;

    recvmsg(fd, &msg, 0);

    int fd_to_read = *(int *)CMSG_DATA(&cm);
    return fd_to_read;
}

int main() {
    int pipefd[2];
    int fd_to_pass = 0;
    // 创建父、子进程间的管道,文件描述符pipefd[0]和pipefd[1]都是UNIX域socket
    int ret = socketpair(PF_UNIX, SOCK_DGRAM, 0, pipefd);
    assert(ret != -1);

    pid_t pid = fork();
    assert(pid >= 0);

    if (pid == 0) {
        close(pipefd[0]);
        fd_to_pass = open("test.txt", O_RDWR, 0666);
        // 子进程通过管道将文件描述符发送到父进程,如果文件打开失败,则子进程将标准输入发送到父进程
        send_fd(pipefd[1], (fd_to_pass > 0) ? fd_to_pass : 0);
        close(fd_to_pass);
        exit(0);
    }

    close(pipefd[1]);
    // 父进程从管道接收目标文件描述符
    fd_to_pass = recv_fd(pipefd[0]);
    char buf[1024];
    memset(buf, '\0', 1024);
    // 读目标文件描述符,验证其有效性
    read(fd_to_pass, buf, 1024);
    printf("I got fd %d and data %s\n", fd_to_pass, buf);
    close(fd_to_pass);
}

第14章 多线程编程

14.1 Linux线程概述

14.1.1 线程模型

​ 线程是程序中完成一个独立任务的完整执行序列,即一个可调度的实体,根据运行环境和调度者的身份,线程可分为内核线程和用户线程。内核线程在有的系统上也称为LWP(Light Weight Process,轻量级进程),运行在内核空间,由内核调度;用户线程运行在用户空间,由线程库调度。当进程的一个内核线程获得CPU的使用权时,它就加载并运行一个用户线程。

一个进程可以拥有M个内核线程和N个用户线程,其中M<=N,并且在一个系统的所有进程中,M和N的比值都是固定的。按照M:N的取值,线程的实现可分为三种模式:完全在用户空间实现、完全由内核调度、双层调度(two level scheduler)。

完全在用户空间实现的线程无须内核的支持,内核甚至不知道这些线程的存在,线程库负责管理所有执行线程,如线程的优先级、时间片等。线程库利用longjmp函数来切换线程的执行,使它们看起来像是并发执行的,但实际上内核仍然是把整个进程作为最小单位来调度,换句话说,一个进程中的所有线程共享该进程的时间片,它们对外表现出相同的优先级(即所有线程使用相同的优先级,因为它们都是以进程为单位调度的)。对这种实现方式而言,N=1,即M个用户线程对应一个内核线程,而该内核线程对实际就是进程本身。完全在用户空间实现的线程的优点:创建和调度线程都无需内核干预,因此速度快,且它不占用额外内核资源,所以即使一个进程创建了很多线程,也不会对系统性能造成明显的影响。其缺点是:对于多处理器系统,一个进程的多个线程无法运行在不同的CPU上,因为内核是按照其最小调度单位来分配CPU的,且线程的优先级只在相对于进程内的其他线程生效,比较不同进程内的线程的优先级无意义。早期的伯克利UNIX线程就是采用这种方式实现的。

完全由内核调度的模式将创建、调度线程的任务交给了内核,运行在用户空间的线程库无须执行管理任务,这与完全在用户空间实现的线程恰恰相反,因此二者的优缺点也正好互换。较早的Linux内核对内核线程的控制能力有限,线程库通常还要提供额外的控制能力,尤其是线程同步机制,但现代Linux内核已经大大增强了对线程的支持。完全由内核调度的线程实现满足M:1=1:1,即1个用户空间线程被映射为1个内核线程。

双层调度模式是两种实现模式的混合体,内核调度M个内核线程,线程库调度N个用户线程,这种线程实现方式结合了前两种方式的优点,不会消耗过多的内核资源,且线程切换速度也较快,同时还能充分利用多处理器优势。

14.1.2 Linux线程库

​ Linux上两个有名的线程库是LinuxThreads和NPTL,它们都采用1:1方式实现。现代Linux上默认使用的线程库是NPTL。

LinuxThreads线程库的内核线程是用clone系统调用创建的进程模拟的,clone系统调用和fork系统调用的作用类似,都创建调用进程的子进程,但我们可以为clone系统调用指定CLONE_THREAD标志,此时它创建的子进程与调用进程共享相同的虚拟地址空间、文件描述符、信号处理函数,这些都是线程的特点。

​ 但用进程模拟内核线程会导致很多语义问题:

  • 1.每个线程拥有不同的PID,不符合POSIX规范。

  • 2.Linux信号处理本来是基于进程的,但现在一个进程内部的所有线程都能且必须处理信号。

  • 3.用户ID、组ID对一个进程中的不同线程来说可能不同。

  • 4.进程产生的核心转储文件不会包含所有线程的信息,而只包含该核心转储文件的线程的信息。

  • 5.由于每个线程都是一个进程,因此系统允许的最大进程数就是最大线程数。

​ LinuxThreads线程库一个有名的特性是所谓的管理线程,它是进程中专门用于管理其他工作线程的线程,其作用为:

  • 1.系统发送给进程的终止信号先由管理线程接收,管理线程再给其他工作线程发送同样的信号以终止它们。

  • 2.当终止工作线程或工作线程主动退出时,管理线程必须等待它们结束,以避免僵尸进程。

  • 3.如果主线程即将先于其他工作线程退出,则管理线程将阻塞主线程,直到所有其他工作线程都结束后才唤醒它。

  • 4.回收每个线程堆栈使用的内存。

管理线程的引入,增加了额外的系统开销,且由于管理线程只能运行在一个CPU上,所以LinuxThreads线程库不能充分利用多处理器系统的优势(所有管理操作只能在一个CPU上完成)。

新的NPTL线程库也应运而生,相比LinuxThreads,NPTL的主要优势在于:

  • 1.内核线程不再是一个进程,因此避免了很多用进程模拟线程导致的语义问题。

  • 2.摒弃了管理线程,终止线程、回收线程堆栈等工作都可以由内核完成。

  • 3.由于不存在管理线程,所以一个进程的线程可以运行在不同CPU上,从而充分利用了多处理器系统的优势。

  • 4.线程的同步由内核来完成,隶属于不同进程的线程之间也能共享互斥锁,因此可实现跨进程的线程同步。

14.2 创建线程和结束线程

​ 创建和结束线程的API在Linux上定义在pthread.h头文件。

#include <pthread.h>

// pthread_t 是 unsigned long int 类型
// thread参数是新线程的标识符,后续pthread_*函数通过它来引用新线程
// attr 设置新线程的属性
// start_routine 是函数指针,就是线程运行的函数,arg 是其参数
// [return] 成功返回 0,失败返回错误码
// 一个用户可以打开的线程数不能超过RLIMIT_NPROC软资源限制
int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*start_routine)(void *), void* arg);

// 通过 retval 参数向线程的回收者传递其退出信息
void pthread_exit(void* retval);

// thread 是目标线程的标识符
// retval 是目标线程返回的退出信息
// [return] 成功返回 0,失败返回错误码
int pthread_join(pthread_t thread, void** retval); // 一个进程中的所有线程都能调用pthread_join函数来回收其他线程, 会一直阻塞,知道被回收的线程结束

// thread 是目标线程的标识符
int pthread_cancel(pthread_t thread);// 异常终止一个线程,即取消线程


// 接收到取消请求的目标线程可以决定是否允许被取消以及如何取消,这通过以下函数完成:
// state参数用于设置线程的取消状态(即是否允许取消),oldstate参数记录线程原来的取消状态。
// type参数设置取消类型(如何取消), oldtype参数记录线程原来的取消类型。
int pthread_setcancelstate(int state, int *oldstate);
int pthread_setcanceltype(int type, int *oldtype);

14.3 线程属性

​ pthread_attr_t 结构体定义了一套完整的线程属性

#include <bits/pthreadtypes.h>

typedef union {
    char __size[__SIZEOF_PTHREAD_ATTR_T];
    long int __align;
} pthread_attr_t;

// 初始化线程属性对象
int pthread_attr_init(pthread_attr_t* attr);

// 销毁线程属性对象,被销毁的线程属性对象只有再次初始化之后才能继续使用
int pthread_attr_destroy(pthread_attr_t* attr);

// 获取和设置线程属性对象的某个属性很熟有很多...

以下是每个线程属性的含义:

  • 1.detachstate:线程的脱离状态,它有PTHREAD_CREATE_JOINABLE和PTHREAD_CREATE_DETACH两个可选值,前者指定线程是可以被回收的,后者使调用线程脱离与其他进程中线程的同步,脱离了与其他线程同步的线程称为脱离线程,脱离线程在退出时将自行释放其占用的系统资源,线程创建时该属性的默认值是PTHREAD_CREATE_JOINABLE,此外,我们也可使用pthread_detach回收直接将线程设置为脱离线程。

  • 2.stackaddr和stacksize:线程堆栈的起始地址和大小,一般,我们不需要自己管理线程堆栈,因为Linux默认为每个线程分配了足够的堆栈空间(一般是8MB),我们可使用ulimit -s命令来查看或修改这个默认值。

  • 3.guardsize:保护区域大小,如果guardsize大于0,则系统创建线程时会在其堆栈尾部额外分配guardsize字节的空间,作为保护堆栈不被错误地覆盖的区域,如果guardsize为0,则系统不为新创建的线程设置堆栈保护区,如果使用者通过pthread_attr_setstackaddr(用于设置线程属性对象的堆栈起始地址)或pthread_attr_setstack(用于设置线程属性对象的堆栈大小和起始地址)函数手动设置线程的堆栈,则guardsize属性将被忽略。

  • 4.schedparam:线程调度参数,其类型是sched_param结构体,该结构体目前只有一个整型成员——sched_priority,表示线程的运行优先级。

  • 5.schedpolicy:线程调度参数,其可选值为

    • (1)SCHED_FIFO:使用先进先出方法调度。

    • (2)SCHED_RR:采用轮转算法(round-robin)调度。

    • (3)SCHED_OTHER:默认值,适用于绝大多数情况,它提供了适度的公平性和响应性,但是由于不确定性,不适合需要严格实时性的应用程序。SCHED_FIFO和SCHED_RR都具备实时调度功能,但只能用于以超级用户身份运行的进程。

  • 6.inheritsched:是否继承调用线程的调度属性,可选值如下:

    • (1)PTHREAD_INHERIT_SCHED:新线程沿用其创建者的线程调度参数,此时忽略新线程的其他调度相关参数。
    • (2)PTHREAD_EXPLICIT_SCHED:调用者要明确指定新线程的调度参数。
  • 7.scope:线程间竞争CPU的范围,即线程优先级的有效范围,POSIX标准定义了该属性以下取值:

    • (1)PTHREAD_SCOPE_SYSTEM:目标线程与系统中所有线程一起竞争CPU的使用。
    • (2)PTHREAD_SCOPE_PROCESS:目标线程仅与其他隶属于同一进程的线程竞争CPU的使用。

14.4 POSIX信号量

​ 下面讨论3种专门用于线程的同步机制:POSIX信号量、互斥量、条件变量。

​ POSIX信号量函数的名字都以sem开头,不像大多线程函数那样以pthread开头。常用的POSIX信号量函数如下:

#include <semaphore.h>

int sem_init(sem_t* sem, int pshared, unsigned int value);
int sem_destroy(sem_t* sem);
int sem_wait(sem_t* sem); // 信号量减1
int sem_trywait(sem_t* sem); // wait 的非阻塞版本
int sem_post(sem_t* sem); // 信号量加1
  • sem_init函数用于初始化一个未命名信号量(POSIX信号量API支持命名信号量,但本书不讨论)。pshared参数指定信号量类型,如果值为0,就表示这个信号量是当前进程的局部信号量,否则该信号量可以在多个进程间共享。value参数指定信号量的初始值。初始化一个已经被初始化的信号量将导致不可预期的结果。

  • sem_destroy函数用于销毁信号量,以释放其占用的内核资源,销毁一个正被其他线程等待的信号量将导致不可预期的结果。

  • sem_wait函数以原子操作的方式将信号量的值减1,如果信号量的值为0,则sem_wait函数将被阻塞,直到这个信号量具有非0值。

  • sem_trywait函数与sem_wait函数类似,但它始终返回,而不论被操作的信号量是否具有非0值,相当于sem_wait函数的非阻塞版本。当信号量非0时,sem_trywait函数对信号量执行减1操作,当信号量的值为0时,该函数返回-1并设置errno为EAGAIN。

  • sem_post函数以原子操作的方式将信号量的值加1,当信号量的值从0变为1时,其他正在调用sem_wait等待信号量的线程将被唤醒。

14.5 互斥锁

​ 互斥锁(也称互斥量)用于保护关键代码段,以确保其独占式的访问,这有些像二进制信号量,当进入关键代码段时,我们需要获得互斥锁并将其加锁,这等价于二进制信号量的P操作,当离开关键代码段时,我们需要对互斥锁解锁,以唤醒其他等待该互斥锁的线程,这相当于二进制信号量的V操作。

14.5.1 互斥锁基础API

​ POSIX互斥锁的相关函数如下:

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t* mutex, const pthread_mutexatrr_t* mutexattr);
int pthread_mutex_destroy(pthread_mutex_t* mutex);
int pthread_mutex_lock(pthread_mutex_t* mutex); // 互斥锁加锁
int pthread_mutex_trylock(pthread_mutex_t* mutex); // lock 的非阻塞版本
int pthread_mutex_unlock(pthread_mutex_t* mutex); // 互斥锁解锁

​ 以上函数的第一个参数mutex指向要操作的目标互斥锁,互斥锁的类型是pthread_mutex_t。

  • ​ pthread_mutex_init函数用于初始化互斥锁,其mutexattr参数指定互斥锁的属性,如果将它设为NULL,则表示使用默认属性。除了该函数外,我们还可以用以下方式初始化一个互斥锁:

    • 在这里插入图片描述

    • ​ 宏PTHREAD_MUTEX_INITIALIZER实际上只是把互斥锁的各个字段都初始化为0。

  • ​ pthread_mutex_destroy函数用于销毁互斥锁,以释放其占用的内核资源。销毁一个加锁的互斥锁将导致不可预期的后果。

  • ​ pthread_mutex_lock函数以原子操作的方式给一个互斥锁加锁,如果目标互斥锁已经被锁上,则pthread_mutex_lock函数将阻塞,直到该互斥锁的占有者将其解锁。

  • ​ pthread_mutex_trylock与pthread_lock函数类似,但它始终返回,而不论被操作的互斥锁是否已经被加锁,相当于pthread_mutex_lock函数的非阻塞版本。当目标互斥锁未被加锁时,pthread_mutex_trylock函数对互斥锁加锁,当互斥锁已被加锁时,pthread_mutex_trylock函数将返回错误码EBUSY。这里讨论的pthread_mutex_lock和pthread_mutex_trylock函数的行为是针对普通锁而言的,对于其他类型的锁,这两个加锁函数有不同的行为。

  • ​ pthread_mutex_unlock函数以原子操作的方式给一个互斥锁解锁,此时如果有其他线程在等待这个互斥锁,则这些线程中的某一个将获得它。

14.5.2 互斥锁属性

​ pthread_mutexattr_t 结构体定义了一套完整的互斥锁属性

#include <pthread.h>
/*初始化互斥锁属性对象*/
int pthread_mutexattr_init(pthread_mutexattr_t* attr);

/*销毁互斥锁属性对象*/
int pthread_mutexattr_destroy(pthread_mutexattr_t* attr);

/*获取和设置互斥锁的 pshared 属性*/
int pthread_mutexattr_getpshared(const pthread_mutexattr_t* attr, int* pshared);
int pthread_mutexattr_setpshared(pthread_mutexattr_t* attr, int pshared);

/*获取和设置互斥锁的 type 属性*/
int pthread_mutexattr_gettype(const pthread_mutexattr_t* attr, int* type);
int pthread_mutexattr_settype(pthread_mutexattr_t* attr, int type);

​ 互斥锁属性pshared指定是否允许跨进程共享互斥锁,其可选值为:

  • 1.PTHREAD_PROCESS_SHARED:互斥锁可以被跨进程共享。
  • 2.PTHREAD_PROCESS_PRIVATE:互斥锁只能和锁的初始化线程隶属于同一个进程的线程共享。

​ 互斥锁属性type指定互斥锁的类型,Linux支持以下4种互斥锁:

  • 1.PTHREAD_MUTEX_NORMAL:普通锁,这是互斥锁的默认类型。当一个线程对一个普通锁加锁后,其余请求该锁的线程将形成一个等待队列,并在该锁解锁后按优先级获得它。这种锁类型保证了资源分配的公平性,但也容易引发问题:一个线程如果对一个已经加锁的普通锁再次加锁,将引发死锁;对一个已经被其他线程加锁的普通锁解锁,或者对一个已经解锁的普通锁再次解锁,将导致不可预期的后果。

  • 2.PTHREAD_MUTEX_ERRORCHECK:检错锁。一个线程如果对一个自己加锁的检错锁再次加锁,则加锁操作返回EDEADLK。对一个已经被其他线程加锁的检错锁解锁,或对一个已经解锁的检错锁再次解锁,则解锁操作返回EPERM。

  • 3.PTHREAD_MUTEX_RECURSIVE:嵌套锁。这种锁允许一个线程在释放锁前多次对它加锁而不发生死锁,但如果其他线程要获得这个锁,则当前锁的拥有者必须执行相应次数的解锁操作。对一个已经被其他线程加锁的嵌套锁解锁,或对一个已经解锁的嵌套锁再次解锁,则解锁操作将返回EPERM。

  • 4.PTHREAD_MUTEX_DEFAULT:默认锁。通常被映射为以上三种锁之一。

14.5.3 死锁举例

​ 死锁使一个或多个线程被挂起而无法继续执行,且这种情况还不容易被发现。在一个线程中对一个已经加锁的普通锁再次加锁将导致死锁,这可能出现在设计得不够仔细的递归函数中。另外,如果两个线程按照不同顺序来申请两个互斥锁,也容易产生死锁,如以下代码所示:

#include <pthread.h>
#include <unistd.h>
#include <stdio.h>

int a = 0;
int b = 0;
pthread_mutex_t mutex_a;
pthread_mutex_t mutex_b;

void *another(void *arg) {
    pthread_mutex_lock(&mutex_b);
    printf("in child thread, got mutex b, waiting for mutex a\n");
    sleep(5);
    ++b;
    pthread_mutex_lock(&mutex_a);
    b += a++;
    pthread_mutex_unlock(&mutex_a);
    pthread_mutex_unlock(&mutex_b);
    pthread_exit(NULL);
}

int main() {
    pthread_t id;

    pthread_mutex_init(&mutex_a, NULL);
    pthread_mutex_init(&mutex_b, NULL);
    pthread_create(&id, NULL, another, NULL);

    pthread_mutex_lock(&mutex_a);
    printf("in parent thread, got mutex a, waiting for mutex b\n");
    sleep(5);
    ++a;
    pthread_mutex_lock(&mutex_b);
    a += b++;
    pthread_mutex_unlock(&mutex_b);
    pthread_mutex_unlock(&mutex_a);

    pthread_join(id, NULL);
    pthread_mutex_destroy(&mutex_a);
    pthread_mutex_destroy(&mutex_b);

    return 0;
}

​ 以上代码中,主线程试图先占有互斥锁mutex_a,然后操作被该锁保护的变量a,但操作完毕后,主线程没有释放互斥锁mutex_a,而是又申请了互斥锁mutex_b,并在两个互斥锁的保护下,操作变量a和b,最后才一起释放这两个互斥锁,与此同时,子线程则按相反的顺序来申请互斥锁mutex_a和mutex_b,并在两个锁的保护下操作变量a和b。我们用sleep函数来模拟两次调用pthread_mutex_lock之间的时间差,以确保代码中的两个线程各自先占有一个互斥锁(主线程占有mutex_a,子线程占有mutex_b),然后等待另一个互斥锁(主线程等待mutex_b,子线程等待mutex_a),这样两个线程就僵持住了,谁也不能继续往下执行,从而形成死锁。如果代码中不加入sleep函数,则这段代码可能能成功运行,从而为程序留下了一个潜在BUG。

14.6 条件变量

​ 如果说互斥锁是用于同步线程对共享数据的访问,那么条件变量则是用于在线程之间同步共享数据的值。条件变量提供了一种线程间的通知机制:当某个共享数据达到某个值时,唤醒等待这个共享数据的线程。

​ 条件变量的相关函数如下:

#include <pthread.h>

int pthread_cond_init(pthread_cond_t* cond, const pthread_condattr_t* cond_attr);
int pthread_cond_destroy(pthread_cond_t* cond);
int pthread_cond_broadcast(pthread_cond_t* cond); // 广播方式唤醒所有等待目标条件变量的线程
int pthread_cond_signal(pthread_cond_t* cond); // 唤醒一个等待目标条件变量的线程
int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex);

​ 上图中函数的第一个参数cond指向要操作的目标条件变量,条件变量的类型是pthread_cond_t。

  • pthread_cond_init函数用于初始化条件变量。cond_attr参数指定条件变量的属性,如果将其设置为NULL,则表示使用默认属性。除了pthread_cond_init函数外,我们还可使用以下方式初始化一个条件变量:

    • 在这里插入图片描述

    • 宏PTHREAD_COND_INITIALIZER实际只是把条件变量的各个字段都初始化为0。

  • pthread_cond_destroy函数用于销毁条件变量,以释放其占用的内核资源。销毁一个正在被等待的条件变量将失败并返回EBUSY。

  • pthread_cond_broadcast函数以广播方式唤醒所有等待目标条件变量的线程。pthread_cond_signal函数用于唤醒一个等待目标条件变量的线程,至于哪个线程将被唤醒,则取决于线程的优先级和调度策略。有时我们可能想唤醒一个指定的线程,但pthread没有对该需求提供解决方法,但我们可以间接地实现该需求:定义一个能唯一表示目标线程的全局变量,在唤醒等待条件变量的线程前先设置该变量为目标线程,然后采用广播方式唤醒所有等待条件变量的线程,这些线程被唤醒后都检查该变量以判断被唤醒的是否是自己,如果是就执行后续代码,否则返回继续等待。

  • pthread_cond_wait函数用于等待目标条件变量,mutex参数是用于保护条件变量的互斥量,以确保pthread_cond_wait函数操作的原子性。在调用pthread_cond_wait前,必须确保互斥锁mutex已经加锁,否则将导致不可预期的结果。pthread_cond_wait函数执行时,会把调用线程放入条件变量的等待队列中并投入睡眠,还会将互斥锁mutex解锁,这两个操作是一个原子操作,从而不会导致解锁互斥锁后,将线程放入等待队列并投入睡眠前这段时间窗口内有其他线程修改了条件并调用pthread_cond_signal或pthread_cond_broadcast,从而在调用pthread_cond_wait的线程被投入睡眠前不会有信号到来(只有在睡眠期间收到信号pthread_cond_wait函数才会返回)。当pthread_cond_wait函数成功返回时,互斥锁mutex将再次被锁上。

14.7 线程同步机制包装类

​ 为了充分复用代码,同时后文需要,我们将前面讨论的三种线程同步机制分别封装为三个类,实现在locker.h头文件中:

#ifndef LOCKER_H
#define LOCKER_H

#include <exception>
#include <pthread.h>
#include <semaphore.h>

// 封装信号量的类
class sem {
public:
    // 创建并初始化信号量
    sem() {
        if (sem_init(&m_sem, 0, 0) != 0) {
            // 构造函数没有返回值,可通过抛出异常来报告错误
            throw std::exception();
        }
    }
 
    // 销毁信号量
    ~sem() {
        sem_destroy(&m_sem);
    }
 
    // 等待信号量
    bool wait() {
        return sem_wait(&m_sem) == 0;
    }
 
    // 增加信号量
    bool post() {
        return sem_post(&m_sem) == 0;
    }

private:
    sem_t m_sem;
};

// 封装互斥锁的类
class locker {
public:
    // 创建并初始化互斥锁
    locker() {
        if (pthread_mutex_init(&m_mutex, NULL) != 0) {
            throw std::exception();
        }
    }

    // 销毁互斥锁
    ~locker() {
        pthread_mutex_destroy(&m_mutex);
    }

    // 获取互斥锁
    bool lock() {
        return pthread_mutex_lock(&m_mutex) == 0;
    }

    // 释放互斥锁
    bool unlock() {
        return pthread_mutex_unlock(&m_mutex) == 0;
    }

private:
    pthread_mutex_t m_mutex;
};

// 封装条件变量的类
class cond {
public:
    // 创建并初始化条件变量
    cond() {
        if (pthread_mutex_init(&m_mutex, NULL) != 0) {
            throw std::exception();
        }
        if (pthread_cond_init(&m_cond, NULL) != 0) {
            // 构造函数中一旦出现问题,就应立即释放已经成功分配的资源
            pthread_mutex_destroy(&m_mutex);
            throw std::exception();
        }
    }

    // 销毁条件变量
    ~cond() {
        pthread_mutex_destroy(&m_mutex);
        pthread_cond_destroy(&m_cond);
    }

    // 等待条件变量
    bool wait() {
        int ret = 0;
        // 作者在此处对互斥锁加锁,保护了什么?这导致其他人无法使用该封装类
        pthread_mutex_lock(&m_mutex);
        ret = pthread_cond_wait(&m_cond, &m_mutex);
        pthread_mutex_unlock(&m_mutex);
        return ret == 0;
    }

    // 唤醒等待条件变量的线程
    bool signal() {
        return pthread_cond_signal(&m_cond) == 0;
    }

private:
    pthread_mutex_t m_mutex;
    pthread_cond_t m_cond;
};

#endif

14.8 多线程环境

14.8.1 可重入函数

如果一个函数能被多个线程同时调用且不发生竞态条件,则我们称它是线程安全的(thread safe),或者说它是可重入函数

​ Linux对很多不可重入的库函数提供了对应的可重入版本,这些可重入版本的函数名是在原函数名尾部加上_r,如localtime函数对应的可重入函数是localtime_r。在多线程程序中调用库函数,一定要使用其可重入版本,否则可能导致预想不到的结果。

14.8.2 线程和进程

​ 如果多线程的某个线程调用了fork函数,那么新创建的子进程只拥有一个执行线程,它是调用fork的那个线程的完整复制,且子进程将自动继承父进程中互斥锁、条件变量的状态,即父进程中已被加锁的互斥锁在子进程中也是被锁住的,这就引起了一个问题:子进程可能不清楚从父进程继承而来的互斥锁的具体状态(是加锁还是解锁状态),这个互斥锁可能被加锁了,但不是由调用fork的线程锁住的,而是由其他线程锁住的,此时,子进程若再次对该互斥锁加锁会导致死锁,如以下代码所示:

#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <wait.h>

pthread_mutex_t mutex;

void prepare() {
    pthread_mutex_lock(&mutex);
}

void infork() {
    pthread_mutex_unlock(&mutex);
}

pthread_atfork(prepare, infork, infork);

// 子线程运行的还是,它首先获得互斥锁mutex,然后暂停5秒,再释放该互斥锁
void *another(void *arg) {
    printf("in child thread, lock the mutex\n");
    pthread_mutex_lock(&mutex);
    sleep(5);
    pthread_mutex_unlock(&mutex);
}

int main() {
    pthread_mutex_init(&mutex, NULL);
    pthread_t id;
    pthread_create(&id, NULL, another, NULL);
    // 父进程中的主线程暂停1秒,以确保在执行fork还是前,子线程已经开始运行并获得了互斥量mutex
    sleep(1);
    int pid = fork();
    if (pid < 0) {
        pthread_join(id, NULL);
        pthread_mutex_destroy(&mutex);
        return 1;
    } else if (pid == 0) {
        printf("I am in the child, want to get the lock\n");
        // 子进程从父进程继承了互斥锁mutex的状态,该互斥锁处于锁住的状态
        // 这是由父进程中的子线程执行pthread_mutex_lock还是引起的,因此以下加锁操作会一直阻塞
        // 尽管从逻辑上来说它是不应该阻塞的
        pthread_mutex_lock(&mutex);
        printf("I can not run to here, oop...\n");
        pthread_mutex_unlock(&mutex);
        exit(0);
    } else {
        wait(NULL);
    }
    pthread_join(id, NULL);
    pthread_mutex_destroy(&mutex);
    return 0;
}

​ pthread提供了一个专门的函数pthread_atfork,以确保fork调用后父进程和子进程都拥有一个清楚的锁状态。

在这里插入图片描述

​ pthread_atfork函数将建立3个fork句柄帮助我们清理互斥锁的状态。

  • prepare句柄将在fork函数创建出子进程前被执行,它可以用来锁住父进程中的互斥锁。
  • parent句柄是fork函数创建出子进程后,fork函数返回前,在父进程中被执行,它的作用是释放所有在prepare句柄中被锁住的互斥锁。
  • child句柄是在fork函数返回前,在子进程中执行,它和parent句柄一样,也是用于释放所有在prepare句柄中被锁住的互斥锁。该函数成功时返回0,失败则返回错误码。
14.8.3 线程和信号

​ 每个线程都能独立设置线程掩码,在多线程环境下应使用pthread_sigmask函数设置信号掩码

在这里插入图片描述

​ 由于进程中所有线程共享该进程的信号,所以线程库将根据线程掩码决定把信号发送给哪个具体的线程。所有线程共享信号处理函数,当我们在一个线程中设置了某个信号的信号处理函数后,它将覆盖其他线程为同一信号设置的信号处理函数。我们应该定义一个专门的线程来处理所有信号

这可通过以下两个步骤实现:

  • 1.在主线程创建出其他子线程前就调用pthread_sigmask来设置好信号掩码,所有新创建的子线程将自动继承这个信号掩码,这样,所有线程都不会响应被屏蔽的信号了。
  • 2.在某个线程中调用以下函数等待信号并处理:
    • 在这里插入图片描述
    • set参数指定要等待的信号的集合,我们可以将其指定为在第一步中创建的信号掩码,表示在该线程中等待所有被屏蔽的信号。参数sig指向的整数用于存储该函数返回的信号值。如果我们使用了sigwait函数,就不应再为信号设置信号处理函数了。

以下代码取自pthread_sigmask函数的man手册,它展示了如何通过以上两个步骤实现在一个线程中统一处理所有信号:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>

// perror函数根据全局errno值打印其相应的错误信息到标准错误
#define handle_error_en(en, msg) \ 
    do { errno = en; perror(msg); exit(EXIT_FAILURE); } while (0)

static void *sig_thread(void *arg) {
    sigset_t *set = (sigset_t *)arg;
    int s, sig;

    for (; ; ) {
        // 第二步,调用sigwait等待信号
        s = sigwait(set, &sig);
        if (s != 0) {
            handle_error_en(s, "sigwait");
        }
        printf("Signal handling thread got signal %d\n", sig);
    }
}

int main(int argc, char *argv[]) {
    pthread_t thread;
    sigset_t set;
    int s;

    // 第一步,在主线程中设置信号掩码
    sigemptyset(&set);
    sigaddset(&set, SIGQUIT);
    sigaddset(&set, SIGUSR1);
    s = pthread_sigmask(SIG_BLOCK, &set, NULL);
    if (s != 0) {
        handle_error_en(s, "pthread_sigmask");
    }

    s = pthread_create(&thread, NULL, &sig_thread, (void *)&set);
    if (s != 0) {
        handle_error_en(s, "thread_create");
    }

    pause();
}

​ pthread还提供了pthread_kill函数,使我们可以把信号发送给指定线程:

在这里插入图片描述

​ thread参数指定目标线程。sig参数指定待发送信号,如果sig参数为0,则pthread_kill不发送信号,但它仍会进行错误检查,我们可用此方法检查目标线程是否存在。pthread_kill函数成功时返回0,失败则返回错误码。

第15章 进程池和线程池

​ 动态创建子进程或子线程的缺点:

  • 1.动态创建进程或线程比较耗时,这将导致较慢的客户响应。
  • 2.动态创建的子进程或子线程通常只用来为一个客户服务(除非我们做特殊处理),这将导致系统上产生大量的进程或线程,进程或线程间的切换将消耗大量CPU时间。
  • 3.动态创建的子进程是当前进程的完整映像,当前进程必须谨慎地管理其分配的文件描述符和堆内存等系统资源,否则子进程会复制这些资源,从而使系统的可用资源急剧下降,进而影响服务器性能。

15.1 进程池和线程池概述

​ 进程池是由服务器预先创建的一组子进程。线程池中的线程数量应该和CPU数量差不多,防止高负载下有CPU核心未被使用。

进程池中的所有子进程都运行着相同的代码,并具有相同的属性,如优先级、PGID等,因为进程池在服务器启动之初时就创建好了,所以每个子进程都相对干净,即它们没有打开不必要的文件描述符(从父进程继承而来),也不会错误地使用大块的堆内存(从父进程复制得到)。

当有新任务与到来时,主进程将通过某种方式选择进程池中某一子进程来为之服务,相比动态创建子进程,选择一个已经存在的子进程的代价小很多,主进程选择哪个子进程来为新任务服务主要有两种方式:

  • 1.主进程使用某种算法主动选择子进程。最简单、最常用的算法是随机算法和Round Robin(轮流选取)算法,但更优秀、更智能的算法将使任务在各个工作进程中更均匀地分配,从而减轻服务器的整体压力。
  • 2.主进程和所有子进程通过一个共享的工作队列来同步,子进程都睡眠在该工作队列上,当有新任务到来时,主进程将任务添加到工作队列中,这将唤醒正在等待任务的子进程,但只有一个子进程能获得新任务的接管权,它可以从工作队列中取出任务并执行之,而其他子进程将继续睡眠在工作队列上。

选好子进程后,主进程还需使用某种通知机制来告诉目标子进程有新任务需要处理,并传递必要的数据,最简单的方法是,在父进程和子进程之间先创建好一条管道,然后通过该管道来实现所有的进程间通信(当然要预先定义好一套协议来规范管道的使用)。在父线程和子线程之间传递数据就要简单得多,因为我们可以把这些数据定义为全局的,那么它们本身就是被所有线程共享的。

在这里插入图片描述

15.2 处理多客户

​ 使用进程池处理多客户任务时,首先要考虑的一个问题是:监听socket和连接socket是否都由主进程来统一管理

​ 第8章中,半同步/半反应堆模式是由主进程统一管理这两种socket的,而更高效的半同步/半异步模式和领导者/追随者模式,则是由主进程管理所有监听socket,而各个子进程分别管理属于自己的连接socket的

  • 半同步/半反应堆模式中,主进程接受新的连接以得到连接socket,然后它需要将该socket传递给子进程(对于线程池而言,父线程将socket传递给子线程是简单的,因为它们可以共享该socket,而对于进程池,我们需要用UNIX域套接字来传递socket);
  • ​ 而领导者/追随者模式的灵活性更大一点,因为子进程可以自己调用accept来接受新连接,这样父进程就无须向子进程传递socket,而只需简单地向子进程通知一声:我检测到了新连接,你来接受它。

常连接指一个客户的多次请求可以复用一个TCP连接,在设计进程池时还要考虑:一个客户连接上的所有任务是否始终由一个子进程来处理,如果客户任务是无状态的,那么我们可以考虑使用不同的子进程来为该客户的不同请求服务,如下图所示:

在这里插入图片描述

​ 但如果客户任务是存在上下文关系的,则最好一直用同一个子进程来为之服务,否则实现起来会比较麻烦,我们将不得不在各子进程之间传递上下文数据。

15.3 半同步/半异步进程池实现

在这里插入图片描述

​ 以下代码实现一个半同步/半异步并发模式的进程池,为了避免在父子进程间传递文件描述符,我们将接受新连接的操作放到子进程中,对于这种模式而言,一个客户连接上的所有任务始终是由一个子进程来处理的:

// filename: processpool.h
#ifndef PROCESSPOOL_H
#define PROCESSPOOL_H

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/stat.h>

// 描述一个子进程的类
class process {
public:
    process() : m_pid(-1) { }

private:
    // 目标子进程的PID
    pid_t m_pid;
    // 父进程和子进程通信用的管道
    int m_pipefd[2];
};

// 进程池类,将它定义为模板类是为了代码复用,其模板参数是处理逻辑任务的类
template <typename T> class processpool {
private:
    // 私有构造函数,只能通过后面的create静态方法来创建processpool实例
    processpool(int listenfd, int process_number = 8);

public:
    // 单体模式,以保证进程最多创建一个processpool实例,这是程序正确处理信号的必要条件
    static processpool<T> *create(int listenfd, int process_number = 8) {
        // 此处有bug,默认new失败会抛异常,而非返回空指针
        if (!m_instance) {
            m_instance = new processpool<T>(listenfd, process_number);
        }
        return m_instance;
    } 
    
    ~processpool() {
        delete[] m_sub_process;
    }
    
    // 启动进程池
    void run();
    
private:
    void setup_sig_pipe();
    void run_parent();
    void run_child();

private:
    // 进程池允许的最大子进程数量
    static const int MAX_PROCESS_NUMBER = 16;
    // 每个子进程最多能处理的客户数量
    static const int USER_PER_PROCESS = 65536;
    // epoll最多能处理的事件数
    static const int MAX_EVENT_NUMBER = 10000;
    // 进程池中的进程总数
    int m_process_number;
    // 子进程在池中的序号,从0开始
    int m_idx;
    // 每个进程都有一个epoll内核事件表,用m_epollfd标识
    int m_epollfd;
    // 监听socket
    int m_listenfd;
    // 子进程通过m_stop决定是否停止运行
    int m_stop;
    // 保存所有子进程的描述信息
    process *m_sub_process;
    // 进程池静态实例
    static processpool<T> *m_instance;
};

teamplate<typename T> processpool<T> *processpool<T>::m_instance = NULL;

// 用来处理信号的管道,以实现统一事件源,后面称之为信号管道
static int sig_pipefd[2];

static int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}

static void addfd(int epollfd, int fd) {
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET;
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
    setnonblocking(fd);
}

// 从epollfd参数标识的epoll内核事件表中删除fd上的所有注册事件
static void removefd(int epollfd, int fd) {
    epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, 0);
    close(fd);
}

static void sig_handler(int sig) {
    int save_errno = errno;
    int msg = sig;
    // 发送的sig的低位1字节,如果主机字节序是大端字节序,则发送的永远是0
    send(sig_pipefd[1], (char *)&msg, 1, 0);
    errno = save_errno;
}

static void addsig(int sig, void handler(int), bool restart = true) {
    struct sigaction sa;
    memset(&sa, '\0', sizeof(sa));
    sa.sa_handler = handler;
    if (restart) {
        sa.sa_flags |= SA_RESTART;
    }
    sigfillset(&sa.sa_mask);
    assert(sigaction(sig, &sa, NULL) != -1);
}

// 进程池的构造函数,参数listenfd是监听socket,它必须在创建进程池前被创建
// 否则子进程无法直接引用它,参数process_number指定进程池中子进程的数量
template<typename T> processpool<T>::processpool(int listenfd, int process_number) 
: m_listenfd(listenfd), m_process_number(process_number), m_idx(-1), m_stop(false) {
    assert((process_number > 0) && (process_number <= MAX_PROCESS_NUMBER));

    // 此处有bug,默认new失败会抛异常,而非返回空指针
    m_sub_process = new process[process_number];
    assert(m_sub_process);
    
    // 创建process_number个子进程,并建立它们和父进程之间的管道
    for (int i = 0; i < process_number; ++i) {
        int ret = socketpair(PF_UNIX, SOCK_STREAM, 0, m_sub_process[i].m_pipefd);
        assert(ret == 0);

        m_sub_process[i].m_pid = fork();
        assert(m_sub_process[i].m_pid >= 0);
        if (m_sub_process[i].m_pid > 0) {
            close(m_sub_process[i].m_pipefd[1]);
            continue;
        } else {
            close(m_sub_process[i].m_pipefd[0]);
            m_idx = i;
            break;
        }
    }
}

// 统一事件源
template<typename T> void processpool<T>::setup_sig_pipe() {
    // 创建epoll事件监听表
    m_epollfd = epoll_create(5);
    assert(m_epollfd != -1);
    
    // 创建信号管道
    int ret = socketpair(PF_UNIX, SOCK_STREAM, 0, sig_pipefd);
    assert(ret != -1);
    
    setnonblocking(sig_pipefd[1]);
    addfd(m_epollfd, sig_pipefd[0]);
    
    // 设置信号处理函数
    addsig(SIGCHLD, sig_handler);
    addsig(SIGTERM, sig_handler);
    addsig(SIGINT, sig_handler);
    addsig(SIGPIPE, SIG_IGN);
}

// 父进程中m_idx值为-1,子进程中m_idx值大于等于0,我们据此判断要运行的是父进程代码还是子进程代码
template<typename T> void processpool<T>::run() {
    if (m_idx != -1) {
        run_child();
        return;
    }
    run_parent();
}

template<typename T> void processpool<T>::run_child() {
    setup_sig_pipe();
    
    // 每个子进程都通过其在进程池中的序号值m_idx找到与父进程通信的管道
    int pipefd = m_sub_process[m_idx].m_pipefd[1];
    // 子进程需要监听管道文件描述符pipefd,因为父进程将通过它通知子进程accept新连接
    addfd(m_epollfd, pipefd);
    
    epoll_event events[MAX_EVENT_NUMBER];
    // 此处有bug,默认new失败会抛异常,而非返回空指针
    T *users = new T[USER_PER_PROCESS];
    assert(users);
    int number = 0;
    int ret = -1;
    
    while (!m_stop) {
        number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
        if ((number < 0) && (errno != EINTR)) {
            printf("epoll failure\n");
            break;
        }
        
        for (int i = 0; i < number; ++i) {
            int sockfd = events[i].data.fd;
            if ((sockfd == pipefd) && (events[i].events & EPOLLIN)) {
                int client = 0;
                ret = recv(sockfd, (char *)&client, sizeof(client), 0);
                if (((ret < 0) && (errno != EAGAIN)) || ret == 0) {
                    continue;
                } else {
                    struct sockaddr_in client_address;
                    socklen_t client_addrlength = sizeof(client_address);
                    int connfd = accept(m_listenfd, (struct sockaddr *)&client_address, 
                                        &client_addrlength);
                    if (connfd < 0) {
                        printf("errno is: %d\n", errno);
                        continue;
                    }
                    addfd(m_epollfd, connfd);
                    // 模板类T必须实现init方法,以初始化一个客户连接,我们直接使用connfd来索引逻辑处理对象(T对象)
                    // 这样效率较高,但比较占用空间(在子进程的堆内存中创建了65535个T对象)
                    users[connfd].init(m_epollfd, connfd, client_address);
                }
            // 处理子进程接收到的信号
            } else if ((sockfd == sigpipefd[0]) && (events[i].events & EPOLLIN)) {
                int sig;
                char signals[1024];
                ret = recv(sig_pipefd[0], signals, sizeof(signals), 0);
                if (ret <= 0) {
                    continue;
                } else {
                    for (int i = 0; i < ret; ++i) {
                        switch (signals[i]) {
                        case SIGCHLD:
                            pid_t pid;
                            int stat;
                            while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) {
                                continue;
                            }    
                            break;
                        
                        case SIGTERM:
                        case SIGINT:
                            m_stop = true;
                            break;
                        
                        default:
                            break;
                        }
                    }
                }
            // 如果是客户发来的请求,则调用逻辑处理对象的process方法处理之
            } else if (events[i].events & EPOLLIN) {
                users[sockfd].process();
            } else {
                continue;
            }
        }
    }
    
    delete[] users;
    users = NULL;
    close(pipefd);
    // 我们将关闭监听描述符的代码注释掉,以提醒读者:应由m_listenfd的创建者来关闭这个文件描述符
    // 即所谓的对象(如文件描述符或一段堆内存)应由创建函数来销毁
    // close(m_listenfd);
    close(m_epollfd);
}

template<typename T> void processpool<T>::run_parent() {
    setup_sig_pipe();
    
    // 父进程监听m_listenfd
    addfd(m_epollfd, m_listenfd);
    
    epoll_event events[MAX_EVENT_NUMBER];
    int sub_process_counter = 0;
    int new_conn = 1;
    int number = 0;
    int ret = -1;
    
    while (!m_stop) {
        number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
        if ((number < 0) && (errno != EINTR)) {
            printf("epoll failure\n");
            break;
        }
        
        for (int i = 0; i < number; ++i) {
            int sockfd = events[i].data.fd;
            if (sockfd == m_listenfd) {
                // 如果有新连接到来,就用Round Robin方式将其分配给一个子进程处理
                int i = sub_process_counter;
                do {
                    if (m_sub_process[i].m_pid != -1) {
                        break;
                    }
                    i = (i + 1) % m_process_number;
                } while (i != sub_process_counter);
                
                if (m_sub_process[i].m_pid == -1) {
                    m_stop = true;
                    break;
                }
                sub_process_counter = (i + 1) % m_process_number;
                send(m_sub_process[i].m_pipefd[0], (char *)&new_conn, sizeof(new_conn), 0);
                printf("send request to child %d\n", i);
            } else if ((sockfd == sig_pipefd[0]) && (events[i].events & EPOLLIN)) {
                int sig;
                char signals[1024];
                ret = recv(sig_pipefd[0], signals, sizeof(signals), 0);
                if (ret <= 0) {
                    continue;
                } else {
                    for (int i = 0; i < ret; ++i) {
                        switch (signals[i]) {
                        case SIGCHLD:
                            pid_t pid;
                            int stat;
                            while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) {
                                for (int i = 0; i < m_process_number; ++i) {
                                    // 如果进程池中第i个进程退出
                                    if (m_sub_process[i].m_pid == pid) {
                                        printf("child %d join\n", i);
                                        // 关闭与该子进程的通信管道
                                        close(m_sub_process[i].m_pipefd[0]);
                                        // 将该子进程的m_pid设为-1,表示该子进程已退出
                                        m_sub_process[i].m_pid = -1;
                                    }
                                }
                            }
                            
                            // 如果所有子进程都已退出,则父进程也退出
                            m_stop = true;
                            for (int i = 0; i < m_process_number; ++i) {
                                if (m_sub_process[i].m_pid != -1) {
                                    m_stop = false;
                                }
                            }
                            break;
                        }
                        break;
                        
                        case SIGTERM:
                        case SIGINT:
                            // 如果父进程接收到终止信号,就杀死所有子进程,并等待它们全部结束
                            // 通知子进程结束更好的方式是向父子进程之间的通信管道发送特殊数据
                            printf("kill all the child now\n");
                            for (int i = 0; i < m_process_number; ++i) {
                                int pid = m_sub_process[i].m_pid;
                                if (pid != -1) {
                                    kill(pid, SIGTERM);
                                }
                            }
                            break;
                        
                        default:
                            break;             
                        }
                    }
                }
            } else {
                continue;
            }
        }
    }
    
    // 由创建者关闭这个文件描述符
    // close(m_listenfd);   
    close(m_epollfd); 
}

#endif

15.4 用进程池实现的简单CGI服务器

​ 复用前面的进程池,构建一个并发的 CGI 服务器

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <sys/wait.h>
#include <sys/stat.h>

// 引用进程池
#include "processpool.h"

// 用于处理客户CGI请求的类,它可作为processpool类的模板参数
class cgi_conn {
public:
    cgi_conn() { }
    
    ~cgi_conn() { }
    
    // 初始化客户连接,清空读缓冲区
    void init(int epollfd, int sockfd, const sockaddr_in &client_addr) {
        m_epollfd = epollfd;
        m_sockfd = sockfd;
        m_address = client_addr;
        memset(m_buf, '\0', BUFFER_SIZE);
        m_read_idx = 0;
    }
    
    void process() {
        int idx = 0;
        int ret = -1;
        // 循环读取和分析客户数据
        while (true) {
            idx = m_read_idx;
            ret = recv(m_sockfd, m_buf + idx, BUFFER_SIZE - 1 - idx, 0);
            // 如果读操作发生错误,则关闭客户连接;如果暂时无数据可读,则退出循环
            if (ret < 0) {
                if (errno != EAGAIN) {
                    removefd(m_epollfd, m_sockfd);
                }
                break;
            // 如果对方关闭连接,则服务器也关闭连接
            } else if (ret == 0) {
                removefd(m_epollfd, m_sockfd);
                break;
            } else {
                m_read_idx += ret;
                printf("user content is: %s\n", m_buf);
                // 如果遇到字符\r\n,则开始处理客户请求
                for (; idx < m_read_idx; ++idx) {
                    if ((idx >= 1) && (m_buf[idx - 1] == '\r') && (m_buf[idx] == '\n')) {
                        break;
                    }
                }
                // 如没有遇到\r\n,则需要读取更多数据
                if (idx == m_read_idx) {
                    continue;
                }
                m_buf[idx - 1] = '\0';
                
                char *file_name = m_buf;
                // 判断客户要运行的CGI程序是否存在
                // access函数用于检测file_name参数表示的文件,F_OK表示检测文件是否存在
                if (access(file_name, F_OK) == -1) {
                    removefd(m_epollfd, m_sockfd);
                    break;
                }
                // 创建子进程执行CGI程序
                ret = fork();
                if (ret == -1) {
                    removefd(m_epollfd, m_sockfd);
                    break;
                } else if (ret > 0) {
                    // 父进程只需关闭连接
                    removefd(m_epollfd, m_sockfd);
                    break;
                } else {
                    // 子进程将标准输出重定向到m_sockfd,并执行CGI程序
                    close(STDOUT_FILENO);
                    dup(m_sockfd);
                    execl(m_buf, m_buf, 0);
                    exit(0);
                }
            }
        }
    }

private:
    static const int BUFFER_SIZE = 1024;
    static int m_epollfd;
    int m_sockfd;
    sockaddr_in m_address;
    char m_buf[BUFFER_SIZE];
    // 标记读缓冲中已经读入的客户数据的最后一个字节的下一个位置
};
int cgi::m_epollfd = -1;

int main(int argc, char *argv[]) {
    if (argc <= 2) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);
    
    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);
    
    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    
    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);
    
    processpool<cgi_conn> *pool = processpool<cgi_conn>::create(listenfd);
    if (pool) {
        pool->run();
        delete pool;
    }
    close(listenfd);    // main函数创建了listenfd,就由它来关闭
    return 0;
}

15.5 半同步/半反应堆线程池实现

在这里插入图片描述

​ 我们接下来实现上图所示的半同步/半反应堆模式的线程池,相比以上进程池的实现,该线程池的通用性要高得多,因为它使用一个工作队列完全解除了主线程和工作线程的耦合关系:主线程往工作队列插入任务,工作线程通过竞争来取得任务并执行它。但要想将该线程池应用到实际服务器程序中,我们必须保证所有客户请求都是无状态的,因为同一个连接上的不同请求可能会由不同的线程处理。

// filename: threadpool.h
#ifndef THREADPOOL_H
#define THREADPOOL_H

#include <list>
#include <cstdio>
#include <exception>
#include <pthread.h>
// 引用第14章中的线程同步机制的包装类
#include "locker.h"

// 线程池类,将它定义为模板类是为了代码复用,模板参数T是任务类
template <typename T> class threadpool {
public:
    // 参数thread_number是线程池中线程的数量,max_requests参数是请求队列中最多允许的、等待处理的请求数量
    threadpool(int thread_number = 8, int max_requests = 10000);
    ~threadpool();
    // 往请求队列中添加任务
    bool append(T *append);

private:
    // 工作线程运行的函数,它不断从工作队列中取出任务并执行之
    static void *worker(void *arg);
    void run();
    
    // 线程池中线程数
    int m_thread_number;
    // 请求队列中允许的最大请求数
    int m_max_requests;
    // 描述线程池的数组,其大小为m_thread_number
    pthread_t *m_threads;
    // 请求队列
    std::list<T *> m_workqueue;
    // 保护请求队列的互斥锁
    locker m_queuelocker;
    // 是否有任务需要处理
    sem m_queuestat;
    // 是否结束线程
    bool m_stop;
};

template <typename T> threadpool<T>::threadpool(int thread_number, int max_requests)
    : m_thread_number(thread_number), m_max_requests(max_requests), m_stop(false), m_threads(NULL) {
    if ((thread_number <= 0) || (max_requests <= 0)) {
        throw std::exception();
    }
    
    // 此处有bug,默认new失败会抛异常,而非返回空指针
    m_threads = new pthread_t[m_thread_number];
    if (!m_threads) {
        throw std::exception();
    }
    
    // 创建thread_number个线程,并将它们都设为脱离线程
    for (int i = 0; i < thread_number; ++i) {
        printf("create the %dth thread\n", i);
        // 第3个参数必须指向一个静态函数,要想在静态函数中使用类的某对象中的成员,只能通过两种方式:
        // 1.通过类的静态对象来调用,如单体模式中,静态函数通过类的全局唯一实例来访问动态成员函数
        // 2.将类的对象作为参数传递给该静态函数,然后在静态函数中使用这个对象,此处就用的这种方式
        // 将线程参数设置为this指针,然后在worker函数中获取该指针
        if (pthread_create(m_threads + i, NULL, worker, this) != 0) {
            delete[] m_threads;
            throw std::exception();
        }
        if (pthread_detach(m_threads[i])) {
            delete[] m_threads;
            throw std::exception();
        }
    }
}

template<typename T> threadpool<T>::~threadpool() {
    delete[] m_threads;
    m_stop = true;
}

template <typename T> bool threadpool<T>::append(T *request) {
    // 操作工作队列前对其加锁,因为所有线程都共享它
    m_queuelocker.lock();
    if (m_workqueue.size() > m_max_requests) {
        m_queuelocker.unlock();
        return false;
    }
    m_workqueue.push_back(request);
    m_queuelocker.unlock();
    m_queuestat.post();
    return true;
}

template<typename T> void *threadpool<T>::worker(void *arg) {
    threadpool *pool = (threadpool *)arg;
    pool->run();
    return pool;
}

template<typename T> void threadpool<T>::run() {
    while (!m_stop) {
        m_queuestat.wait();
        m_queuelocker.lock();
        if (m_workqueue.empty()) {
            m_queuelocker.unlock();
            continue;
        }
        T *request = m_workqueue.front();
        m_workqueue.pop_front();
        m_queuelocker.unlock();
        if (!request) {
            continue;
        }
        request->process();
    }
}

#endif

15.6 用线程池实现的简单 web 服务器

15.6.1 http_conn类

​ 首先我们需要准备线程池的模板参数类用来封装对逻辑任务的处理,这个类是http_conn,以下代码是其头文件http_conn.h:

#ifndef HTTPCONNECTION_H
#define HTTPCONNECTION_H

#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <sys/stat.h>
#include <string.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <stdarg.h>
#include <errno.h>
#include "locker.h"

class http_conn {
public:
    // 文件名的最大长度
    static const int FILENAME_LEN = 200;
    // 读缓冲区的大小
    static const int READ_BUFFER_SIZE = 2048;
    // HTTP请求方法,但我们仅支持GET
    enum METHOD {GET = 0, POST, HEAD, PUT, DELETE, TRACE, OPTIONS, CONNECT, PATCH};
    // 解析客户请求时,主状态机所处的状态
    enum CHECK_STATE {CHECK_STATE_REQUESTLINE = 0, CHECK_STATE_HEADER, CHECK_STATE_CONTENT};
    // 服务器处理HTTP请求的结果:
	// NO_REQUEST:请求不完整,需要继续读取客户数据
	// GET_REQUEST:获得了一个完整的客户请求
	// BAD_REQUEST:客户请求有语法错误
	// FORBIDDEN_REQUEST:客户对资源没有足够的访问权限
	// INTERNAL_ERROR:服务器内部错误
	// CLOSED_CONNECTION:客户已经关闭连接
    enum HTTP_CODE {NO_REQUEST, GET_REQUEST, BAD_REQUEST, NO_RESOURCE, FORBIDDEN_REQUEST,
                    FILE_REQUEST, INTERNAL_ERROR, CLOSED_CONNECTION};
    // 行的读取状态
    enum LINE_STATUS {LINE_OK = 0, LINE_BAD, LINE_OPEN};
    
    http_conn();
    ~http_conn();
    
    // 初始化新接受的连接
    void init(int sockfd, const sockaddr_in &addr);
    // 关闭连接
    void close_conn(bool real_close = true);
    // 处理客户请求
    void process();
    // 非阻塞读操作
    bool read();
    // 非阻塞写操作
    bool write();
    
    // 所有socket上的事件都被注册到同一epoll内核事件表中,所以将epoll文件描述符设置为静态的
    static int m_epollfd;
    // 统计用户数量
    static int m_user_count;

private:
    // 初始化连接
    void init();
    // 解析HTTP请求
    HTTP_CODE process_read();
    // 填充HTTP应答
    bool process_write(HTTP_CODE ret);
    
    // 下面一组函数被process_read函数调用以分析HTTP请求
    HTTP_CODE parse_request_line(char *text);
    HTTP_CODE parse_headers(char *text);
    HTTP_CODE parse_content(char *text);
    HTTP_CODE do_request();
    char *get_line() {
        return m_read_buf + m_start_line;
    }
    LINE_STATUS parse_line();
    
    // 下面一组函数被process_write函数调用以填充HTTP应答
    void unmap();
    bool add_response(const char *format, ...);
    bool add_content(const char *content);
    bool add_status_line(int status, const char *title);
    bool add_headers(int content_length);
    bool add_content_length(int content_length);
    bool add_linger();
    bool add_blank_line();
    
    // 该HTTP连接的socket和对方的socket地址
    int m_sockfd;
    sockaddr_in m_address;
    
    // 读缓冲区
    char m_read_buf[READ_BUFFER_SIZE];
    // 标识读缓冲中已经读入的客户数据的最后一个字节的下一个位置
    int m_read_idx;
    // 当前正在分析的字符在读缓冲区中的位置
    int m_checked_idx;
    // 当前正在解析的行的起始位置
    int m_start_line;
    // 写缓冲区
    char m_write_buf[WRITE_BUFFER_SIZE];
    // 写缓冲区中待发送的字节数
    int m_write_idx;
    
    // 主状态机当前所处的状态
    CHECK_STATE m_check_state;
    // 请求方法
    METHOD m_method;
    
    // 客户请求的目标文件的完整路径,其内容等于doc_root+m_url,doc_root是网站根目录
    char m_real_file[FILENAME_LEN];
    // 客户请求的目标文件的文件名
    char *m_url;
    // HTTP版本号,我们仅支持HTTP/1.1
    char *m_version;
    // 主机名
    char *host;
    // HTTP请求的消息体的长度
    int m_content_length;
    // HTTP请求是否要求保持连接
    bool m_linger;
    
    // 客户请求的目标文件被mmap到内存中的起始位置
    char *m_file_address;
    // 目标文件的状态,可通过它判断文件是否存在、是否是目录、是否可读、文件大小等信息
    struct stat m_file_stat;
    // 我们将采用writev函数来执行写操作,所以定义以下成员,其中m_iv_count表示被写内存块的数量
    struct iovec m_iv[2];
    int m_iv_count;
};

#endif

​ 以下是类http_conn的实现文件http_conn.cpp:

#include "http_conn.h"

// 定义HTTP响应的状态信息
const char *ok_200_title = "OK";
const char *error_400_title = "Bad Request";
const char *error_400_form = "Your request has bad syntax or is inherently impossible to satisfy.\n";
const char *error_403_title = "Forbidden";
const char *error_403_form = "You do not have permission to get file from this server.\n";
const char *error_404_title = "Not Found";
const char *error_404_form = "The requested file was not found on this server.\n";
const char *error_500_title = "Internal Error";
const char *error_500_form = "There was an unusual problem serving the requested file.\n";
const char *doc_root = "/var/www/html";

int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}

void addfd(int epollfd, int fd, bool one_shot) {
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
    if (one_shot) {
        event.events |= EPOLLONESHOT;
    }
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
    setnonblocking(fd);
}

void removefd(int epollfd, int fd) {
    // epoll_ctl函数的第4个参数是epoll_event类型指针,用于描述与文件描述符fd参数相关的事件以及关联的数据
    // 此处执行删除操作,只需要指定要删除的文件描述符
    epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, 0);
    close(fd);
}

void modfd(int epollfd, int fd, int ev) {
    epoll_event event;
    event.data.fd = fd;
    event.events = ev | EPOLLET | EPOLLONESHOT | EPOLLRDHUP;
    epoll_ctl(epollfd, EPOLL_CTL_MOD, fd, &event);
}

int http_conn::m_user_count = 0;
int http_conn::m_epollfd = -1;

void http_conn::close_conn(bool real_close) {
    // 如果real_close为true且当前连接的socket存在(该连接的socket,即m_sockfd,不为-1)
    if (read_close && (m_sockfd != -1)) {
        removefd(m_epollfd, m_sockfd);
        m_sockfd = -1;
        // 关闭一个连接时,将客户总数减1
        --m_user_count;
    }
}

void http_conn::init(int sockfd, const sockaddr_in &addr) {
    m_sockfd = sockfd;
    m_address = addr;
    // 以下两行是为了避免TIME_WAIT状态,仅用于调试,实际使用时应去掉
    int reuse = 1;
    setsockopt(m_sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
    addfd(m_epollfd, sockfd, true);
    ++m_user_count;
    
    init();
}

void http_conn::init() {
    m_check_state = CHECK_STATE_REQUESTLINE;
    m_linger = false;
    
    m_method = GET;
    m_url = 0;
    m_version = 0;
    m_content_length = 0;
    m_host = 0;
    m_start_line = 0;
    m_checked_idx = 0;
    m_read_idx = 0;
    m_write_idx = 0;
    memset(m_read_buf, '\0', READ_BUFFER_SIZE);
    memset(m_write_buf, '\0', WRITE_BUFFER_SIZE);
    memset(m_real_file, '\0', FILENAME_LEN);
}

// 从状态机
http_conn::LINE_STATUS http_conn::parse_line() {
    char temp;
    for (; m_checked_idx < m_read_idx; ++m_checked_idx) {
        temp = m_read_buf[m_checked_idx];
        if (temp == '\r') {
            if ((m_checked_idx + 1) == m_read_idx) {
                return LINE_OPEN;
            } else if (m_read_buf[m_checked_idx + 1] == '\n') {
                m_read_buf[m_checked_idx++] = '\0';
                m_read_buf[m_checked_idx++] = '\0';
                return LINE_OK;
            }
            
            return LINE_BAD;
        } else if (temp == '\n') {
            if ((m_checked_idx > 1) && (m_read_buf[m_checked_idx - 1] == '\r')) {
                m_read_buf[m_checked_idx - 1] = '\0';
                m_read_buf[m_checked_idx++] = '\0';
                return LINE_OK;
            }
            return LINE_BAD;
        }
    }
    
    return LINE_OPEN;
}

bool http_conn::read() {
    if (m_read_idx >= READ_BUFFER_SIZE) {
        return false;
    }
    
    int bytes_read = 0;
    while (true) {
        bytes_read = recv(m_sockfd, m_read_buf + m_read_idx, READ_BUFFER_SIZE - m_read_idx, 0);
        if (bytes_read == -1) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                break;
            }
            return false;
        } else if (bytes_read == 0) {
            return false;
        }
        
        m_read_idx += bytes_read;
    }
    return true;
}

// 解析HTTP请求行,获得请求方法、目标URL、HTTP版本号
// 我们预期text的格式类似GET /path/to/resource HTTP/1.1
http_conn::HTTP_CODE http_conn::parse_request_line(char *text) {
    // strpbrk函数用于在一个字符串中查找第一个包含在指定字符集合中的字符,并返回该字符在字符串中的位置
    m_url = strpbrk(text, " \t");
    if (!m_url) {
        return BAD_REQUEST;
    }
    *m_url++ = '\0';
    
    char *method = text;
    if (strcasecmp(method, "GET") == 0) {
        m_method = GET;
    } else {
        return BAD_REQUEST;
    }
    
    // strspn函数返回一个size_t类型的值,表示在第一个参数中从开头开始的连续字符数量
    // 这些字符都包含在第二个参数中的字符集合中
    m_url += strspn(m_url, " \t");
    m_version = strpbrk(m_url, " \t");
    if (!m_version) {
        return BAD_REQUEST;
    }
    *m_version++ = '\0';
    m_version += strspn(m_version, " \t");
    if (strcasecmp(m_version, "HTTP/1.1") != 0) {
        return BAD_REQUEST;
    }
    
    if (strncasecmp(m_url, "http://", 7) == 0) {
        m_url += 7;
        // strchr函数在一个字符串中查找指定字符的第一次出现的位置,并返回该位置的指针
        m_url = strchr(m_url, '/');
    }
    
    if (!m_url || m_url[0] != '/') {
        return BAD_REQUEST;
    }
    
    m_check_state = CHECK_STATE_HEADER;
    return NO_REQUEST;
}

// 解析HTTP请求的一个头部信息
http_conn::HTTP_CODE http_conn::parse_headers(char *text) {
    // 遇到空行,表示不再有头部字段
    if (text[0] == '\0') {
        // 如果HTTP请求有消息体,则还需读取m_content_length字节的消息体
        // 状态机转移到CHECK_STATE_CONTENT状态
        if (m_content_length != 0) {
            m_check_state = CHECK_STATE_CONTENT;
            return NO_REQUEST;
        }
        
        // 否则说明我们已经得到了一个完整HTTP请求
        return GET_REQUEST;
    } else if (strncasecmp(text, "Connection:", 11) == 0) {
            text += 11;
            text += strspn(text, " \t");
            if (strcasecmp(text, "keep-alive") == 0) {
                m_linger = true;
            }
        }
    } else if (strncasecmp(text, "Content-Length:", 15) == 0) {
        text += 15;
        text += strspn(text, " \t");
        m_content_length = atoi(text);
    } else if (strncasecmp(text, "Host:", 5) == 0) {
        text += 5;
        text += strspn(text, " \t");
        m_host = text;
    } else {
        printf("oop! unknown header %s\n", text);
    }
    
    return NO_REQUEST;
}

// 我们没有真正解析HTTP请求的消息体,只是判断它是否被完整读入了
http_conn::HTTP_CODE http_conn::parse_content(char *text) {
    if (m_read_idx >= m_content_length + m_checked_idx) {
        text[m_content_length] = '\0';
        return GET_REQUEST;
    }
    
    return NO_REQUEST;
}

// 主状态机
http_conn::HTTP_CODE http_conn::process_read() {
    LINE_STATUS line_status = LINE_OK;
    HTTP_CODE ret = NO_REQUEST;
    char *text = 0;
    
    while (((m_check_state == CHECK_STATE_CONTENT) && (line_status == LINE_OK)) 
           || ((line_status = parse_line()) == LINE_OK)) {
        text = get_line();
        m_start_line = m_checked_idx;
        printf("got 1 http line: %s\n", text);
        
        switch (m_check_state) {
        case CHECK_STATE_REQUESTLINE:
            ret = parse_request_line(text);
            if (ret == BAD_REQUEST) {
                return BAD_REQUEST;
            }
            break;
        
        case CHECK_STATE_HEADER:
            ret = parse_headers(text);
            if (ret == BAD_REQUEST) {
                return BAD_REQUEST;
            } else if (ret == GET_REQUEST) {
                return do_request();
            }
            break;
         
        default:
            return INTERNAL_ERROR;   
        }
    }
    
    return NO_REQUEST;
}

// 得到一个完整、正确的HTTP请求时,该函数分析目标文件的属性
// 如果目标文件存在、对所有用户可读、不是目录,则使用mmap函数将其映射到内存地址m_file_address处
// 并告诉调用者获取文件成功
http_conn::HTTP_CODE http_coonn::do_request() {
    strcpy(m_real_file, doc_root);
    int len = strlen(doc_root);
    strncpy(m_real_file + len, m_url, FILENAME_LEN - len - 1);
    if (stat(m_real_file, &m_file_stat) < 0) {
        return NO_RESOURCE;
    }
    
    if (!(m_file_stat.st_mode & S_IROTH)) {
        return FORBIDDEN_REQUEST;
    }
    
    if (S_ISDIR(m_file_stat.st_mode)) {
        return BAD_REQUEST;
    }
    
    int fd = open(m_real_file, O_RDONLY);
    m_file_address = (char *)mmap(0, m_file_stat.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    close(fd);
    return FILE_REQUEST;
}

// 对内存映射区执行munmap操作
void http_conn::unmap() {
    if (m_file_address) {
        munmap(m_file_address, m_file_stat.st_size);
        m_file_address = 0;
    }
}

// 写HTTP响应
bool http_conn::write() {
    int temp = 0;
    int bytes_have_send = 0;
    int bytes_to_send = m_write_idx;
    if (bytes_to_send == 0) {
        modfd(m_epollfd, m_sockfd, EPOLLIN);
        init();
        return true;
    }
    
    while (1) {
        temp = writev(m_sockfd, m_iv, m_iv_count);
        if (temp <= -1) {
            // 如果TCP写缓冲区没有空间,则等待下一轮EPOLLOUT事件
            // 虽然在此期间,服务器无法立即收到同一客户的下一请求,但这可保证同一连接的完整性
            if (errno == EAGAIN) {
                modfd(m_epollfd, m_sockfd, EPOLLOUT);
                return true;
            }
            unmap();
            return false;
        }
        
        bytes_to_send -= temp;
        bytes_have_send += temp;
        if (bytes_to_send <= bytes_have_send) {
            // 发送HTTP响应成功
            unmap();
            if (m_linger) {
                // 此处处理完一个请求后,直接调用init清空了读缓冲区
                // 如果客户连续发送多个请求,读缓冲区中可能有多于一个请求的数据
                // 会丢失请求,如果读缓冲中某个请求只读了一半,则接下来的读操作会读入另一半
                // 然后由于只有一半请求而被认为是请求语法有问题
                init();
                modfd(m_epollfd, m_sockfd, EPOLLIN);
                return true;
            } else {
                modfd(m_epollfd, m_sockfd, EPOLLIN);
                return false;
            }
        }
    }
}

bool http_conn::add_response(const char *format, ...) {
    if (m_write_idx >= WRITE_BUFFER_SIZE) {
        return false;
    }
    va_list arg_list;
    va_start(arg_list, format);
    // vsnprintf函数会根据format字符串中的格式控制码,将可变参数列表中的值格式化后写入str所指向的缓存区
    // 该函数返回写入缓冲区的字节数,包含结尾的\0
    // 如果缓冲区太小,则该函数返回要写入的数据的字节数(此时不包含结尾的\0)
    // 因此,如果该函数返回值大于等于第二个参数的大小,说明缓冲区太小
    int len = vsnprintf(m_write_buf + m_write_idx, WRITE_BUFFER_SIZE - 1 - m_write_idx, format, arg_list);
    if (len >= (WRITE_BUFFER_SIZE - 1 - m_write_idx)) {
        return false;
    }
    m_write_idx += len;
    // va_start函数和va_end函数必须成对出现,va_end函数用于清理可变参数列表,用于避免潜在的内存泄漏或数据损坏
    va_end(arg_list);
    return true;
}

bool http_conn::add_status_line(int status, const char *title) {
    return add_response("%s %d %s\r\n", "HTTP/1.1", status, title);
}

bool http::conn::add_headers(int content_len) {
    add_content_length(content_len);
    add_linger();
    add_blank_line();
}

bool http_conn::add_content_length(int content_len) {
    return add_response("Content-Length: %d\r\n", content_len);
}

bool http_conn::add_linger() {
    return add_response("Connection: %s\r\n", (m_linger == true) ? "keep-alive" : "close");
}

bool http_conn::add_blank_line() {
    return add_response("%s", "\r\n");
}

bool http_conn::add_content(const char *content) {
    return add_response("%s", content);
}

// 根据服务器处理HTTP请求的结果,决定返回给客户端的内容
bool http_conn::process_write(HTTP_CODE ret) {
    switch (ret) {
    case INTERNAL_ERROR:
        add_status_line(500, error_500_title);
        add_headers(strlen(error_500_form));
        if (!add_content(error_500_form)) {
            return false;
        }
        break;
    
    case BAD_REQUEST:
        add_status_line(400, error_400_title);
        add_headers(strlen(error_400_form));
        if (!add_content(error_400_form)) {
            return false;
        }
        break;
    
    case NO_RESOURCE:
        add_status_line(400, error_400_title);
        add_headers(strlen(error_404_form));
        if (!add_content(error_404_form)) {
            return false;
        }
        break;
       
    case FORBIDDEN_REQUEST:
        add_status_line(403, error_403_title);
        add_headers(strlen(error_403_form));
        if (!add_content(error_403_form)) {
            return false;
        }    
        break;

    case FILE_REQUEST:
        add_status_line(200, ok_200_title);
        if (m_file_stat.st_size != 0) {
            add_headers(m_file_stat.st_size);
            m_iv[0].iov_base = m_write_buf;
            m_iv[0].iov_len = m_write_idx;
            m_iv[1].iov_base = m_file_address;
            m_iv[1].iov_len = m_file_stat.st_size;
            m_iv_count = 2;
            return true;
        } else {
            const char *ok_string = "<html><body></body></html>";
            add_headers(strlen(ok_string));
            if (!add_content(ok_string)) {
                return false;
            }
        }
        break;
    
    default:
        return false;    
    }
    
    m_iv[0].iov_base = m_write_buf;
    m_iv[0].iov_len = m_write_idx;
    m_iv_count = 1;
    return true;
}

// 由线程池中的工作线程调用,这是处理HTTP请求的入口
void http_conn::process() {
    HTTP_CODE read_ret = process_read();
    if (read_ret == NO_REQUEST) {
        modfd(m_epollfd, m_sockfd, EPOLLIN);
        return;
    }
    
    bool write_ret = process_write(read_ret);
    if (!write_ret) {
        close_conn();
    }
    modfd(m_epollfd, m_sockfd, EPOLLOUT);
}

15.6.2 main函数

​ 定义好任务类后,main函数只需负责IO读写即可:

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <cssert>
#include <sys/epoll.h>
#include <libgen.h>

#include "locker.h"
#include "threadpool.h"
#include "http_conn.h"

#define MAX_FD 65536
#define MAX_EVENT_NUMBER 10000

extern int addfd(int epollfd, int fd, bool one_shot);
extern int removefd(int epollfd, int fd);

void addsig(int sig, void handler(int), bool restart = true) {
    struct sigaction sa;
    memset(&sa, '\0', sizeof(sa));
    sa.sa_handler = handler;
    if (restart) {
        sa.sa_flags |= SA_RESTART;
    }
    sigfillset(&sa.sa_mask);
    assert(sigaction(sig, &sa, NULL) != -1);
}

void show_error(int connfd, const char *info) {
    printf("%s", info);
    send(connfd, info, strlen(info), 0);
    close(connfd);
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);
    
    // 忽略SIGPIPE信号
    addsig(SIGPIPE, SIG_IGN);
    
    // 创建线程池
    threadpool<http_conn> *pool = NULL;
    try {
        pool = new threadpool<http_conn>;
    // 捕获所有异常
    } catch ( ... ) {
        return 1;
    }
    
    // 预先为每个可能的客户连接分配一个http_conn对象
    // 此处有bug,默认new失败会抛异常,而非返回空指针
    http_conn *users = new http_conn[MAX_FD];
    assert(users);
    int user_count = 0;
    
    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);
    // 关闭连接时,直接给对面发送RST
    struct linger tmp = {1, 0};
    setsockopt(listenfd, SOL_SOCKET, SO_LINGER, &tmp, sizeof(tmp));
    
    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    
    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret >= 0);
    
    ret = listen(listenfd, 5);
    assert(ret >= 0);
    
    epoll_event events[MAX_EVENT_NUMBER];
    int epollfd = epoll_create(5);
    assert(epollfd != -1);
    addfd(epollfd, listenfd, false);
    http_conn:m_epollfd = epollfd;
    
    while (true) {
        int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
        if (number < 0 && errno != EINTR) {
            printf("epoll failure\n");
            break;
        }
  • 10
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值