UNPv1 学习笔记
序
国庆闲来无事,决定认真补一补基础,就从网络编程开始吧。
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_buff 取 sizeof 的问题……
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 这本书没太大关系了),不过可能也不会在一行一行写代码调试学习了。有新的成果再更新吧。