UDP套接字编程

UDP协议是相对于TCP协议不是面向连接的,也是不可靠的,因此UDP套接字编程在思路上和TCP套接字编程很不一样。

普通的UDP套接字

sentto函数和recvfrom函数

sentto函数和recvfrom函数比面向连接(稍后并不仅指TCP)的sendrecv函数多了flag和表示送达和接收地址的SA
容易看到这两个函数是适合UDP这样的无连接协议的。对于客户端来说,相当于将connect函数功能去掉,然后每次都显式传地址。对于服务端来说,它也不需要accept函数,每次recvfrom过来,它都可以取到这是从谁发过来的。甚至recvfromSA参数可以设为nullptr,这样表示我接受所有信息,不管是谁发的。
注意recvfrom传入的最后一个长度参数必须是已经初始化后的。否则UDP函数返回的地址和端口都会是0。

普通的UDP套接字存在的问题

异步错误

上面这样的设计看起来似乎很好,但考虑当服务端进程未开启,那从客户端过来的UDP包是送不到的,这时候recvfrom阻塞了。UNP书中给出了echo服务的例子,需要注意的是在Ubuntu的终端中我们可以仍可以输入,但实际上线程是阻塞的。
但是对客户端来说并不是这样,因为sendto函数是立即返回的(不返回也没有意义啊,毕竟无连接的,并不能指望对方一定会回复)。但对系统来说,这个包并不是就这么杳无音信的,因为服务端会发一个端口不可达的ICMP过来,可惜这个不会被客户端的进程接受(原因稍后论述)。并且这个ICMP是具有时延,所以它也不能被立即返回的sendto接受。这样的错误称为异步错误
BTW,注意ICMP本身也是不可靠的,可能会被丢掉。所以客户端存在收不到ICMP的情况,这可能是因为数据报根本没发过去,也可能是对方主机回复的ICMP丢了。TCP甚至有个专门的Duplicate ACK机制来解决“是发过去的包丢了还是返回的ACK丢了”的问题。

为什么对应进程收不到端口不可达的ICMP

考虑刚才的recvfrom函数,假如说给recvfrom函数设置一个超时(Ch14.2),那么它就不会在端口不可达时永远陷入阻塞,此时我们考虑它的行为。
假设一个多宿的UDP套接口(它可向多个IP发送数据包)向另外3个服务器发送了数据包,然后阻塞在reccfrom上等待回应。这三台服务器中前两台开启了相应的端口并给出了回应,但是第三台服务器是端口不可达的,因而对端回复了ICMP报文。按照直截了当的思路,内核应当把源IP和端口等信息写到recvfromSA * from参数里面,然后设置errno = -1,这样recvfrom就可以超时返回了,客户端对应进程也就能知道刚才的的一个sendto失败了。
作者指出从逻辑上就是不现实的,这是因为在recvfrom函数的返回值里面不能知晓是自己发送的3个UDP包中发向哪个服务器的包出现了问题,导致自己收不到回应,因为返回的errno无法承载IP地址信息。不过讲得并不清楚。
这是由于sendto函数调用后,它是立即返回的,此时内核已经释放了和对端套接字相关的数据结构,当端口不可达的ICMP过来时,内核无法追踪出对应的套接字了,所以它无法通知上层的应用。
基于上面的两点原因,最好不要重复利用UDP套接口,也就是说将它连接到一个对端。因此对UDP也有了connect函数。
重新考虑上面的问题,在connect的情况下,内核中记录了这个五元组(源IP, 源port, 宿IP, 宿port, 协议)的状态。如果对端传来了ICMP消息,内核可以取出ICMP中的端口号,然后根据记录的五元组找到相应的进程。因此进程就能收到这个错误,虽然还是异步的。

“连接的”套接字

一个不面向连接的UDP套接字不能收到它引发的异步错误,除非它已经被connect。但这里的connect和TCP中的并不一样,它没有TCP中三次握手的过程,事实上它仅仅在内核中注册了一下,并未和对端服务器进行交互。此时应当使用writesend来代替sendto(硬要用sendto需要将第5个参数即宿地址设为nullptr),用readrecvrecvmsg来代替recvfrom,这是因为connect声明了我们的套接口只和某个ip:port进行交互了。已连接的套接字会忽略其他IP或端口传来的数据报。
同理,断开UDP的“连接”也不需要进行四次挥手,而是直接用AF_UNSPEC参数再次调用connect函数即可。对于不同的POSIX系统,还有其他各不相同的方法。

套接口参数

SO_REUSEPORT、SO_REUSEADDR

这个 Linux3.9 版本带来的新特性,类似于一个负载均衡。在使用 SO_REUSEPORT 后,多个进程可以同时 bind 和 listen 同一个 ip:port产生多个套接字,然后由内核决定将新连接发送给谁。当然,监听这些端口的套接口只能在同一个用户(effective user id)下面,这是为了保证安全性。
特别说明,因为套接口连接是五元组,所以对于同一个四元组,可以建立 tcp 连接和 udp 连接。但是如果不开启 SO_REUSEPORT 选项,是不能多个套接口绑定到一个 port 上的。可以简单想一想,此时一个 tcp 包来了,如何选择对应的套接字呢?没有 SO_REUSEPORT 的情况下,默认报错;有 SO_REUSEPORT 的情况下,我们随机选一个。
但是在文章中指出,并不一定能够实现负载均衡。

网络编程相关

epoll

QA

  1. 是不是所有 fd 都可以被 epoll
    并不是的,需要实现 poll 函数的fd。

    1
    2
    struct file_operations {
    __poll_t (*poll) (struct file *, struct poll_table_struct *);
  2. 为什么要有等待队列
    所有等待这个 epoll 的进程都会在等待队列上面,这个有点类似于 Python 的 Condition 里面的 waiters 数组。

  3. 水平和边沿触发

  4. 为什么 epoll 快

    1. 只有调用 epoll_ctl 的时候才拷贝 fd,而不是在 epoll_wait 的时候拷贝。
    2. epoll_wait 不会遍历所有的 fd,而是只判断就绪队列是否为空。
    3. epoll_ctl 的复杂度是 O(log(n)) 的,这是因为引入了红黑树。
    4. 回调机制
  5. 为什么 epoll 使用红黑树
    socket 的句柄是 key,socket 对象是红黑树的节点。方便快速找到对应的 socket 对象,从而快速修改监听 socket 的读写事件。

一个 epoll 的demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;

/* Code to set up listening socket, 'listen_sock',
(socket(), bind(), listen()) omitted. */

epollfd = epoll_create1(0);
if (epollfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}

ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}

for (;;) {
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}

for (n = 0; n < nfds; ++n) {
if (events[n].data.fd == listen_sock) {
conn_sock = accept(listen_sock,
(struct sockaddr *) &addr, &addrlen);
if (conn_sock == -1) {
perror("accept");
exit(EXIT_FAILURE);
}
setnonblocking(conn_sock);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
&ev) == -1) {
perror("epoll_ctl: conn_sock");
exit(EXIT_FAILURE);
}
} else {
do_use_fd(events[n].data.fd);
}
}
}