国庆闲来无事,决定认真补一补基础,就从网络编程开始吧。

UNPv1 大概是门好书,这些东西用 C 写也比较舒心,语法简单,噪音小;而同时又比较琐碎,很多细节不得不亲自下手处理。之前没想过 socket()bind()listen()accept()connect()close()read()write() 就这几个东西可以组合得这么有趣,也通过学习这些把平时没怎么用过的 Wireshark 和 GDB 用起来了。

总体看来还是有好处的。如果为了快速上手不如直接用网络库等等,不过嘛,这样一来就又回到以前 API Caller 的学习节奏了。虽然绝大多数情况可以直接用网络库,但想成为内功深厚的人至少要知道网络库要怎么造才对。

另外,GDB 和 Clion Debugger 的 UI 确实好用。跟着走一圈,打几个断点再配合 Wireshark,网络流程非常清晰;同时,以前觉得玄学的 select()poll() 这些也不再神秘,完全看得到运行流程(相比之下 select() 还是神秘,poll() 更清晰一点 )。

(总的来说这就是一篇抄代码日记,嗯。)

基本方式

书上最开始的客户端-服务端程序就是 Echo,所以给出的代码也是如此。跟着敲一敲,改改变量命名,换换风格之类,能够比较好的学习这个过程。

先列出一个基础版本的好了。

服务端程序如下。

#include <errno.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

#define MAX_BUFF 8
#define PORT 8080

void handle(int fd);

int main(void) {
    int sock_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (sock_fd < 0) {
        perror("Failed to create socket");
        exit(EXIT_FAILURE);
    }

    struct sockaddr_in srv_addr, cli_addr;
    bzero(&srv_addr, sizeof(srv_addr));
    bzero(&cli_addr, sizeof(cli_addr));
    srv_addr.sin_family = AF_INET;
    srv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    srv_addr.sin_port = htons(PORT);

    if (bind(sock_fd, (struct sockaddr *) &srv_addr, sizeof(srv_addr)) < 0) {
        perror("Failed to bind socket");
        exit(EXIT_FAILURE);
    }

    if (listen(sock_fd, SOMAXCONN) < 0) {
        perror("Failed to listen to socket");
        exit(EXIT_FAILURE);
    }

    while (true) {
        socklen_t cli_addr_size = sizeof(cli_addr);
        int conn_fd = accept(sock_fd, (struct sockaddr *) &cli_addr, &cli_addr_size);
        if (conn_fd < 0) {
            perror("Failed to create connection");
            exit(EXIT_FAILURE);
        }
        pthread_t t;
        pthread_create(&t, NULL, (void *(*)(void *)) handle, (void *) conn_fd);
    }
    return 0;
}

void handle(int fd) {
    pthread_detach(pthread_self());
    char recv_buff[MAX_BUFF];
    bzero(&recv_buff, sizeof(recv_buff));
    while (true) {
        ssize_t size = read(fd, recv_buff, sizeof(recv_buff));
        if (size > 0) {
            write(fd, recv_buff, size);
            fputs(recv_buff, stdout);
            bzero(&recv_buff, sizeof(recv_buff));
        } else if (errno == EINTR)
            continue;
        else
            break;
    }
    close(fd);
}

客户端程序如下。

#include <netinet/in.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

#define MAX_BUFF 8
#define PORT 8080

int main(void) {
    int sock_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (sock_fd < 0) {
        perror("Failed to create socket");
        exit(EXIT_FAILURE);
    }

    struct sockaddr_in addr;
    bzero(&addr, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(PORT);

    while (true) {
        if (connect(sock_fd, (struct sockaddr *) &addr, sizeof(addr)) < 0) {
            perror("Failed to create connection");
            exit(EXIT_FAILURE);
        }
        char send_buff[MAX_BUFF], recv_buff[MAX_BUFF];
        bzero(&send_buff, sizeof(send_buff));
        bzero(&recv_buff, sizeof(recv_buff));
        while ((fgets(send_buff, sizeof(send_buff), stdin) != NULL)) {
            write(sock_fd, send_buff, strlen(send_buff));
            bzero(&send_buff, sizeof(send_buff));
            if (read(sock_fd, recv_buff, sizeof(recv_buff)) > 0)
                fputs(recv_buff, stdout);
            bzero(&recv_buff, sizeof(recv_buff));
        }
        close(sock_fd);
    }
    return 0;
}

这里服务端程序用的是多线程,per connection per thread 的方式虽然不好,初学也就先不着眼于此,将就这样了。如果换用多进程只需要在 accept() 然后 fork() == 0 的段内写逻辑即可。

这里还有一个有趣的事情。之前曾把 void handle() 中的循环放到了另一个函数中,却一直有问题。查询思索半天,最终发现……问题是对其中的 char *recv_buffsizeof 的问题……

void size(char *a) {
    printf("%d\n", sizeof(a));
}

int main(void) {
    char a[100];
    printf("%d\n", sizeof(a));
    size(a);
    return 0;
}

结果如下。

100
8

数组的 size 信息在退化为指针时丢失应该是基本的常识了,可惜平时写纯 C 不多,对这里不够敏感。

此外,写这个的时候为了方便观察错误信息所以都做了错误处理,也是以前不常做的。唯一的感想…… Go 语言的 if (err != nil) 没什么问题,虽然没有 Rust 那么优雅,不过比 C 里这样更统一、更清晰。

客户端 select

书中在第六章就开始引入 I/O 多路复用的知识,接着就着手用 select() 改写客户端。改写过后,可能驱动逻辑没有单纯通过 fgets() 驱动直观,但如果用 GDB 跟踪后就会理解 select() 的运作原理了。

(此处再次发牢骚……需要使用一堆宏……宏的具体实现是一堆 __asm__ ……真的没有 poll() 清晰……)

#include <netinet/in.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>

#define MAX_BUFF 8
#define PORT 8080

int main(void) {
    int sock_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (sock_fd < 0) {
        perror("Failed to create socket");
        exit(EXIT_FAILURE);
    }

    struct sockaddr_in addr;
    bzero(&addr, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(PORT);
    int opt = 1;
    setsockopt(sock_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    if (connect(sock_fd, (struct sockaddr *) &addr, sizeof(addr)) < 0) {
        perror("Failed to create connection");
        exit(EXIT_FAILURE);
    }
    char buff[MAX_BUFF];
    bzero(&buff, sizeof(buff));
    int max_fdp = 0, in_eof = 0;
    fd_set set;
    FD_ZERO(&set);

    while (true) {
        if (in_eof == 0)
            FD_SET(fileno(stdin), &set);
        FD_SET(sock_fd, &set);
        max_fdp = (fileno(stdin) > sock_fd ? fileno(stdin) : sock_fd) + 1;
        if (select(max_fdp, &set, NULL, NULL, NULL) < 0) {
            perror("Select error");
            exit(EXIT_FAILURE);
        }
        if (FD_ISSET(sock_fd, &set)) {
            ssize_t size;
            if ((size = read(sock_fd, buff, sizeof(buff))) == 0) {
                if (in_eof == 1)
                    break;
                else {
                    fprintf(stderr, "Read error: Server terminated");
                    exit(EXIT_FAILURE);
                }
            }
            write(fileno(stdout), buff, size);
        }
        if (FD_ISSET(fileno(stdin), &set)) {
            ssize_t size;
            if ((size = read(fileno(stdin), buff, sizeof(buff))) == 0) {
                in_eof = 1;
                if (shutdown(sock_fd, SHUT_WR) < 0) {
                    perror("Failed to shutdown");
                    exit(EXIT_FAILURE);
                }
                FD_CLR(fileno(stdin), &set);
                continue;
            }
            write(sock_fd, buff, size);
        }
    }
    close(sock_fd);
    return 0;
}

如书上所说,之前针对 fgets() 驱动的客户端程序被改写成了 select() 驱动,同时为了避免和 stdio 冲突改为了针对缓冲区的操作。

服务端 poll

书上介绍了对服务端应用 select(),就不重复了,直接用 poll() 对服务端进行改写。

这里的 pollfd 数组中第一项(即第 0 项)存储 bind() listen() 的文件描述符,之后的位置用于存储 accept() 接受连接的文件描述符。代码如下。

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

#define MAX_BUFF 8
#define OPEN_MAX 1024
#define PORT 8080

int main(void) {
    int listen_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (listen_fd < 0) {
        perror("Failed to create socket");
        exit(EXIT_FAILURE);
    }
    int sock_fd = 0;

    struct sockaddr_in srv_addr;
    bzero(&srv_addr, sizeof(srv_addr));
    srv_addr.sin_family = AF_INET;
    srv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    srv_addr.sin_port = htons(PORT);

    if (bind(listen_fd, (struct sockaddr *) &srv_addr, sizeof(srv_addr)) < 0) {
        perror("Failed to bind socket");
        exit(EXIT_FAILURE);
    }

    if (listen(listen_fd, SOMAXCONN) < 0) {
        perror("Failed to listen to socket");
        exit(EXIT_FAILURE);
    }

    char buff[MAX_BUFF];
    bzero(&buff, sizeof(buff));
    struct pollfd client[OPEN_MAX];
    client[0].fd = listen_fd;
    client[0].events = POLLRDNORM;
    for (int i = 1; i < OPEN_MAX; i++)
        client[i].fd = -1;
    int cap = 0;
    int ready = 0;

    while (true) {
        if ((ready = poll(client, cap + 1, -1)) < 0) {
            perror("Failed to poll fds");
            exit(EXIT_FAILURE);
        }
        if (client[0].revents & POLLRDNORM) {
            struct sockaddr_in cli_addr;
            socklen_t cli_addr_size = sizeof(cli_addr);
            int conn_fd = accept(listen_fd, (struct sockaddr *) &cli_addr, &cli_addr_size);
            if (conn_fd < 0) {
                perror("Failed to accept connection");
                exit(EXIT_FAILURE);
            }
            for (int i = 1; i < OPEN_MAX; i++)
                if (client[i].fd < 0) {
                    client[i].fd = conn_fd;
                    if (i == OPEN_MAX) {
                        fprintf(stderr, "Failed to poll: Too many clients");
                        exit(EXIT_FAILURE);
                    }
                    client[i].events = POLLRDNORM;
                    cap = (i > cap ? i : cap);
                    break;
                }
            if (--ready <= 0)
                continue;
        }

        for (int i = 1; i <= cap; i++) {
            if ((sock_fd = client[i].fd) < 0)
                continue;
            if (client[i].revents & (POLLRDNORM | POLLERR)) {
                ssize_t n;
                if ((n = read(sock_fd, buff, sizeof(buff))) < 0) {
                    if (errno == ECONNRESET) {
                        if (close(sock_fd) < 0) {
                            perror("Failed to close socket");
                            exit(EXIT_FAILURE);
                        }
                        client[i].fd = -1;
                    } else {
                        fprintf(stderr, "Failed to read: Empty");
                        exit(EXIT_FAILURE);
                    }
                } else if (n == 0) {
                    if (close(sock_fd) < 0) {
                        perror("Failed to close socket");
                        exit(EXIT_FAILURE);
                    }
                    client[i].fd = -1;
                } else {
                    write(sock_fd, buff, n);
                    fputs(buff, stdout);
                }
                if (--ready <= 0)
                    break;
            }
        }
    }
    return 0;
}

写好后做一下测试(去掉 fputs(buff, stdout) 这一行)。

client="./client"

r[1]=$'example\n'
r[2]=$'test\n'
r[3]=$'newline\n'
r[4]=$'qwerasdfzxcv\n'
r[5]=$'donotcreatetoolong\n'
r[6]=$'0123546789876543210\n'
r[7]=$'a\n'
r[8]=$'thatisit\n'
r[9]=$'somethingwillbewrong\n'
r[10]=$'toobig\n'

for (( i = 0; i < 65536; i++ )); do
    ${client} <<<${r[1]}${r[2]}${r[3]}${r[4]}${r[5]}${r[6]}${r[7]}${r[8]}${r[9]}${r[10]} > log/${i}.log 2>&1 &
done

风扇疯转,不过内存用量不高。总体来看还是单进程依次处理连接,没有多进程/多线程/协程等处理连接。

0.84s user 7.38s system 7% cpu 1:55.45 total

观察了一下输出,都没有问题。正确可用的 Echo Server 就这样建立完成了。

服务端 epoll

正如其名,UNPv1 主要针对 Unix 系统,因此,没有涉及 Linux 平台特定的 epoll。网络上的资料……还是不发牢骚了……变量名结构完全一致的中文复读机……嗯。

没办法抄书上的代码了,找了一本《Linux/Unix 系统编程手册》,侧重介绍 Linux 上的一些 API(顺便推荐一下,这大概也是一本好书,比 APUE 这样的字典有更多原理介绍)。书上介绍了 Linux 下 epoll 相关的函数及使用,但是也没有 Socket 编程的代码。因此,只能理解大概后自己手写了。

好在,几种 I/O 多路复用都有类似之处,可以触类旁通。参照之前的 poll 服务器做修改即可。

#include <netinet/in.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>

#define MAX_BUFF 8
#define OPEN_MAX 1024
#define PORT 8080

int main(void) {
    int listen_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (listen_fd < 0) {
        perror("Failed to create socket");
        exit(EXIT_FAILURE);
    }
    int sock_fd = 0;

    struct sockaddr_in srv_addr;
    bzero(&srv_addr, sizeof(srv_addr));
    srv_addr.sin_family = AF_INET;
    srv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    srv_addr.sin_port = htons(PORT);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    if (bind(listen_fd, (struct sockaddr *) &srv_addr, sizeof(srv_addr)) < 0) {
        perror("Failed to bind socket");
        exit(EXIT_FAILURE);
    }

    if (listen(listen_fd, SOMAXCONN) < 0) {
        perror("Failed to listen to socket");
        exit(EXIT_FAILURE);
    }

    char buff[MAX_BUFF];
    bzero(&buff, sizeof(buff));
    struct epoll_event event;
    struct epoll_event events[OPEN_MAX];
    bzero(&event, sizeof(event));
    bzero(events, sizeof(events));

    int epoll_fd = 0;
    if ((epoll_fd = epoll_create(65536)) < 0) {
        perror("Failed to create epoll instance");
        exit(EXIT_FAILURE);
    }
    event.events = EPOLLIN;
    event.data.fd = listen_fd;
    if ((epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &event) < 0)) {
        perror("Failed to add epoll event");
        exit(EXIT_FAILURE);
    }
    int cap = 0;
    int ready = 0;

    while (true) {
        if ((ready = epoll_wait(epoll_fd, events, cap + 1, -1)) < 0) {
            perror("Failed to poll fds");
            exit(EXIT_FAILURE);
        }

        for (int i = 0; i < ready; i++) {
            if (events[i].data.fd == listen_fd && (events[i].events & EPOLLIN)) {
                struct sockaddr_in cli_addr;
                socklen_t cli_addr_size = sizeof(cli_addr);
                int conn_fd = accept(listen_fd, (struct sockaddr *) &cli_addr, &cli_addr_size);
                if (conn_fd < 0) {
                    perror("Failed to accept connection");
                    exit(EXIT_FAILURE);
                }
                event.events = EPOLLIN;
                event.data.fd = conn_fd;
                if ((epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &event) < 0)) {
                    perror("Failed to add epoll event");
                    exit(EXIT_FAILURE);
                }
                cap++;
                continue;
            } else if (events[i].events & EPOLLIN) {
                ssize_t n;
                sock_fd = events[i].data.fd;
                if ((n = read(sock_fd, buff, sizeof(buff))) <= 0) {
                    if (close(sock_fd) < 0) {
                        perror("Failed to close socket");
                        exit(EXIT_FAILURE);
                    }
                    continue;
                } else {
                    write(sock_fd, buff, n);
                    write(fileno(stdout), buff, n);
                }
            } else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) {
                epoll_ctl(epoll_fd, EPOLL_CTL_DEL, events[i].data.fd, &events[i]);
                cap--;
                close(events[i].data.fd);
            }
        }
    }
    close(listen_fd);
    return 0;
}

再次跟踪一下运行过程,可以更好地理解 epoll 的运行机制。

poll 的需要通过 poll() 检查 struct pollfd 的数组,每次返回已就绪文件描述符个数;然后每次遍历所有文件描述符,直到已经处理完需要处理的已就绪文件描述符为止。如《Linux/Unix 系统编程手册》书中所说,poll() 无法“记住”需要操作的文件描述符。

epoll 则不同,如上述程序所示,每次添加文件描述符时,只需要通过 epoll_ctl() EPOLL_CTL_ADD 将其添加到内核中由 epoll_create() 建立的数据结构中,而不像 poll() 一样需要添加到程序维护的数组中。而当文件描述符已经就绪需要操作时,则是通过 epoll_wait() 传入 struct epoll_event 的数组指针填充获得,之后只需要遍历这个结果即可。

意外的是,对 epoll 进行之前 poll 时进行的测试时发现性能几乎没有太大变化。应该不是时间测量的问题,而是测试手法和程序结构的问题,因为按照目前的程序结构,同时接收 65536 个客户的进程都是依次处理,单个单个处理。可能唯一的区别就是 epoll 每次只需要遍历一项数组,poll 每次多遍历一些,因此目前性能上没有变化也是正常的。

HTTP Server

休息了几天,回来以后把上面的小玩意改成了一个简单的 HTTP Server。其实也说不上是 Server,只是简单改造,做了个雏形,对任何 HTTP 请求都返回 “Hello, world!” 这样的。

这样的 HTTP Server 难度并不大,在 TRPL 一书中,Rust 初学完毕后的内容就是实现一个多线程(自行实现线程池)的 Web Server。但是,这终究还是不太一样的,封装会掩盖很多细节。我确定我并非“一切都是汇编”那样否定各个编程语言、各个库的封装的那类人,不过学习原理时,特别是在 Linux 上,系统级 API 更贴近一些“原理”,总比 Node.js 的 http 或者藏在 CGI 背后的 PHP 更靠近。在抽象思维能力有限的前提下,这样的东西足够直观。

测试的东西在这里代码这里就不再放了。

至此,一个最基本的服务器的雏形就构建完毕了(实际上远远不止,还差许多……)。


接下来应该还继续学习非阻塞 I/O、多线程/线程池处理连接、网络库、事件库等(好像很多和 UNPv1 这本书没太大关系了),不过可能也不会在一行一行写代码调试学习了。有新的成果再更新吧。