网络编程实战
网络编程实战
第四讲
调用 connect 函数将激发 TCP 的三次握手过程,而且仅在连接建立成功或出错时才返回。其中出错返回可能有以下几种情况:
- 三次握手无法建立,客户端发出的 SYN 包没有任何响应,于是返回 TIMEOUT 错误。这种情况比较常见的原因是对应的服务端 IP 写错。
- 客户端收到了 RST(复位)回答,这时候客户端会立即返回 CONNECTION REFUSED 错误。这种情况比较常见于客户端发送连接请求时的请求端口写错,因为 RST 是 TCP 在发生错误时发送的一种 TCP 分节。关闭时也会产出
RST
报文与SO_LINGER
选项有关
产生 RST 的三个条件是:
目的地为某端口的 SYN 到达,然而该端口上没有正在监听的服务器(如前所述);
TCP 想取消一个已有连接;
TCP 接收到一个根本不存在的连接上的分节。 - 客户发出的 SYN 包在网络上引起了”destination unreachable”,即目的不可达的错误。这种情况比较常见的原因是客户端和服务器端路由不通。
为什么tcp建立连接需要三次握手解释如下
tcp连接的双方要确保各自的收发消息的能力都是正常的。 客户端第一次发送握手消息到服务端, 服务端接收到握手消息后把ack
和自己的syn
一同发送给客户端,这是第二次握手, 当客户端接收到服务端发送来的第二次握手消息后,客户端可以确认“服务端的收发能力OK,客户端的收发能力OK”,但是服务端只能确认 “客户端的发送OK,服务端的接收OK” , 所以还需要第三次握手,客户端收到服务端的第二次握手消息后,发起第三次握手消息,服务端收到客户端发送的第三次握手消息后,就能够确定“服务端的发送OK,客户端的接收OK”, 至此,客户端和服务端都能够确认自己和对方的收发能力OK,,tcp
连接建立完成。
第五讲
阻塞式套接字最终发送返回的实际写入字节数和请求字节数是相等的即
1 | int ret = write(sockfd , void* buffer , int len); |
当sockfd
是阻塞状态时,只有将buffer中len字节数据放入输出缓冲区后write函数才返回。
既然缓冲区如此重要,我们可不可以把缓冲区搞得大大的,这样不就可以提高应用程序的吞吐量了么?你可以想一想这个方法可行吗?另外你可以自己总结一下,一段数据流从应用程序发送端,一直到应用程序接收端,总共经过了多少次拷贝?
无限大肯定是不行的,这要从为什么使用缓存这个角度考虑。内核协议栈不确定用户一次要发多少数据,如果用户来一次就发一次,如果数据多还好说,如果少了,那网络I/O很频繁,而真正发送出去的数据也不多,所以为了减少网络I/O使用了缓存的策略。但为啥不呢无限大呢,网卡一次发出去的数据报它是有一个最大长度的,所以你不管累积再多数据最后还是要分片发送的,这样一来缓冲区太大也没什么意义,而且数据传输也是有延时要求的,不可能总是在缓冲区里待着等数据,这样就总会有空出来的缓冲区存放新数据,所以无限大缓冲区也没意义,反而还浪费资源。
发送端,假设数据能一次性复制完,那么从用户态内存拷贝到内核态内存是一次(这里应该直接拷贝到发送换冲区了),传输层组TCP包是第二次拷贝,因为要加包头,而发送缓冲区的都是紧凑内存全是应用层数据,那么分装包就需要一次拷贝,第三次,一个TCP包封装为IP报文这里可能也会需要一次拷贝,毕竟这里走到协议栈的下一层了。
第六讲
实际上不存在UDP
发送缓冲区,因为发往UDP
发送缓冲区的包只要超过一定阈值(值很小)就可以发往对端。所以我们一般认为UDP
是没有发送缓冲区的。
UDP
报文的大小
主要影响 UDP
报文大小的三大因素:
UDP
协议规定报文长度为 16 位,所以UDP
的报文长度不能超过 2^16 = 65536 字节- 以太网(Ethernet)数据帧的长度,这是由以太网的物理特性决定,也叫数据链路层的
MTU
(最大传输单元) - socket 的
UDP
发送缓冲区大小
UDP
最大数据包长度
根据 UDP
协议,从 UDP
数据包的包头可以看出,UDP
的最大包长度是 2^16-1 个字节。用sendto
函数最大能发送数据的长度为:65535- IP头(20) - UDP头(8)=65507字节
。用sendto
函数发送数据时,如果发送数据长度大于该值,则函数会返回错误。
由于 UDP
包头占 8 个字节,而在 IP
层进行封装后的 IP
包头占去 20 字节,所以这个是 UDP
数据包的最大理论长度是 2^16 - 1 - 8 - 20 = 65507 字节。
同时 UDP
发送缓冲区大小(linux
下UDP
发送缓冲区大小为:cat /proc/sys/net/core/wmem_default
)相关,肯定不能超过缓冲区大小。
UDP
理想数据包长度
每个以太网帧都有最小的大小 46 字节,最大不能超过 1500 字节。
除去链路层的首部和尾部的 18 个字节,链路层的数据区范围是 46-1500 字节,
那么链路层的数据区,即 MTU
(最大传输单元)为 1500 字节。
事实上这个 1500 字节就是网络层 IP
数据报的长度限制。
因为 IP
数据报的首部为 20 字节,所以 IP
数据报的数据区长度最大为 1480 字节。而这个 1480 字节就是用来放 TCP 传来的 TCP
报文段或 UDP
传来的 UDP
数据报的。
除去 UDP
包头占 8 个 字节,那么 UDP
数据报的数据区最大长度为 1472 字节。
结论1:局域网环境下,建议将 UDP
数据控制在 1472 字节以下
Unix 网络编程第一卷里说:ipv4
协议规定 ip
层的最小重组缓冲区大小为 576 字节,所以将 UDP
数据报的数据区最大长度控制在 548 字节(576-8-20)以内。
结论2:Internet
编程时,建议将 UDP
数据控制在 548 字节以下
第十讲
TIME_WAIT的作用: 1. 确保主动断开方的最后一个ACK成功发到对方 2. 确保残留的TCP包自然消亡
优化TIME_WAIT
,可以通过设置套接字选项来设置调用close或shutdown关闭连接时的行为
1 | int setsockopt(int sockfd, int level, int optname, const void *optval,socklen_t optlen); |
设置 linger 参数有几种可能:
如果
l_onoff
为 0,那么关闭本选项。l_linger的值被忽略,这对应了默认行为,close
或shutdown
立即返回。如果在套接字发送缓冲区中有数据残留,系统会将试着把这些数据发送出去。如果
l_onoff
为非 0, 且l_linger
值也为 0,那么调用close
后,会立该发送一个RST
标志给对端,该TCP
连接将跳过四次挥手,也就跳过了TIME_WAIT
状态,直接关闭。这种关闭的方式称为“强行关闭”。 在这种情况下,排队数据不会被发送,被动关闭方也不知道对端已经彻底断开。只有当被动关闭方正阻塞在recv()
调用上时,接受到RST
时,会立刻得到一个“connet reset by peer”
的异常。如果
l_onoff
为非 0, 且l_linger
的值也非 0,那么调用close
后,调用close
的线程就将阻塞,直到数据被发送出去,或者设置的l_linger
计时时间到。1
2
3
4struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 1;
setsockopt(s,SOL_SOCKET,SO_LINGER, &so_linger,sizeof(so_linger));
对于设置端口重用选项 SO_REUSEADDR 并不是用于解决 TIME_WAIT 状态,而是告诉内核即使是TIME_WAIT状态的套接字,也可以将它继续使用作为新的套接字使用
第十一讲
close 函数具体是如何关闭两个方向的数据流呢?
在输入方向,系统内核会将该套接字设置为不可读,任何读操作都会返回异常。
在输出方向,系统内核尝试将发送缓冲区的数据发送给对端,并最后向对端发送一个 FIN 报文,接下来如果再对该套接字进行写操作会返回异常。
如果对端没有检测到套接字已关闭,还继续发送报文,就会收到一个 RST 报文
由于close将输入设置为不可读,当服务端要做耗时任务时,由于客户端调用close()导致输入方向不可读,此时服务端运算完成返回tcp报文,但是客户端socket不可读,故内核协议栈回复RST报文
关于signal函数:
1 | sighandler_t signal(int signum, sighandler_t handler); |
- 如果处理方式设置为
SIG_IGN
,则信号被忽略。 - 如果处理方式设置为
SIG_DFL
,则与信号相关的默认操作(参考 signal(7))发生。
1 |
你可以看到在今天的服务器端程序中,直接调用exit(0)完成了 FIN 报文的发送,这是为什么呢?为什么不调用 close 函数或 shutdown 函数呢?
因为在调用exit之后进程会退出,而进程相关的所有的资源,文件,内存,信号等内核分配的资源都会被释放,在linux
中,一切皆文件,本身socket就是一种文件类型,内核会为每一个打开的文件创建file
结构并维护指向改结构的引用计数,每一个进程结构中都会维护本进程打开的文件数组,数组下标就是fd
,内容就指向上面的file
结构,close
本身就可以用来操作所有的文件,做的事就是,删除本进程打开的文件数组中指定的fd
项,并把指向的file
结构中的引用计数减一,等引用计数为 0 的时候,就会调用内部包含的文件操作close
,针对于socket
,它内部的实现就是调用shutdown
,只是参数是关闭读写端,从而比较粗暴的关闭连接。
第十二讲
socket设置保活选项
1 | void Socket::setKeepAlive(bool on) const { |
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。
上述的可定义变量,分别被称为保活时间、保活时间间隔和保活探测次数。在 Linux
系统中,这些变量分别对应 sysctl
变量net.ipv4.tcp_keepalive_time
、net.ipv4.tcp_keepalive_intvl
、 net.ipv4.tcp_keepalve_probes
,默认设置是 7200 秒(2 小时)、75 秒和 9 次探测。
由于TCP自身的KeepAlive
机制所需的时间太长,对很多对时延要求敏感的系统中,这个时间间隔是不可接受的。所以通常自实现心跳机制
第十三讲
Nagle算法 和 延迟ACK 的组合:
客户端分两次将一个请求发送出去,由于请求的第一部分的报文未被确认,Nagle 算法开始起作用;同时延时 ACK 在服务器端起作用,假设延时时间为 200ms,服务器等待 200ms 后,对请求的第一部分进行确认;接下来客户端收到了确认后,Nagle 算法解除请求第二部分的阻止,让第二部分得以发送出去,服务器端在收到之后,进行处理应答,同时将第二部分的确认捎带发送出去。
Nagle 算法和延时确认组合在一起,增大了处理时延,实际上,两个优化彼此在阻止对方。从上面的例子可以看到,在有些情况下 Nagle 算法并不适用, 比如对时延敏感的应用
1 | int on = 1; //关闭 Nagle 算法 |
值得注意的是,除非我们对此有十足的把握,否则不要轻易改变默认的 TCP Nagle 算法。因为在现代操作系统中,针对 Nagle 算法和延时 ACK 的优化已经非常成熟了,有可能在禁用 Nagle 算法之后,性能问题反而更加严重。
第十五讲
重用套接字选项,通过给套接字配置可重用属性,告诉操作系统内核,这样的 TCP 连接完全可以复用 TIME_WAIT 状态的连接
1 | int on = 1; |
SO_REUSEADDR
套接字选项还有一个作用,那就是本机服务器如果有多个地址(ip地址),可以在不同地址上使用相同的端口提供服务。
要在创建socket和bind之间设置 SO_REUSEADDR
套接字选项 因为SO_REUSEADDR
是针对新建立的连接才起作用,对已建立的连接设置是无效的。
第十七讲
- 网络中断造成的对端无 FIN 包
很多原因都会造成网络中断,在这种情况下,TCP 程序并不能及时感知到异常信息。除非网络中的其他设备,如路由器发出一条 ICMP 报文,说明目的网络或主机不可达,这个时候通过 read 或 write 调用就会返回 Unreachable 的错误。
大多数时候并不是如此,在没有 ICMP 报文的情况下,TCP 程序并不能理解感应到连接异常。如果程序是阻塞在 read 调用上,那么很不幸,程序无法从异常中恢复。
如果程序先调用了 write 操作发送了一段数据流,接下来阻塞在 read 调用上,结果会非常不同。Linux 系统的 TCP 协议栈会不断尝试将发送缓冲区的数据发送出去,大概在重传 12 次、合计时间约为 9 分钟之后,协议栈会标识该连接异常,这时,阻塞的 read 调用会返回一条 TIMEOUT 的错误信息。如果此时程序还执着地往这条连接写数据,写操作会立即失败,返回一个 SIGPIPE 信号给应用程序。
- 系统崩溃造成的对端无 FIN 包
当系统突然崩溃,如断电时,网络连接上来不及发出任何东西。这里和通过系统调用杀死应用程序非常不同的是,没有任何 FIN 包被发送出来。
在没有 ICMP 报文的情况下,TCP 程序只能通过 read 和 write 调用得到网络连接异常的信息,超时错误是一个常见的结果。
系统在崩溃之后又重启,当重传的 TCP 分组到达重启后的系统,由于系统中没有该 TCP 分组对应的连接数据,系统会返回一个 RST 重置分节,TCP 程序通过 read 或 write 调用可以分别对 RST 进行错误处理。
如果是阻塞的 read 调用,会立即返回一个错误,错误信息为连接重置(Connection Reset)。
如果是一次 write 操作,也会立即失败,应用程序会被返回一个 SIGPIPE 信号。
- 对端有FIN包发出
对端如果有 FIN 包发出,可能的场景是对端调用了 close 或 shutdown 显式地关闭了连接,也可能是对端应用程序崩溃,操作系统内核代为清理所发出的。从应用程序角度上看,无法区分是哪种情形。
第十八讲
当服务器完全崩溃或网络故障,如果采用阻塞读,将无法感知到套接字异常,将会一直阻塞,可以为read
设置超时,果超过了一段时间就认为连接已经不存在
1 | struct timeval tv; |
第十九讲
一个进程无论是正常退出(exit 或者 main 函数返回),还是非正常退出(比如,收到 SIGKILL 信号关闭,就是我们常常干的 kill -9),所有该进程打开的描述符都会被系统关闭,这也导致 TCP 描述符对应的连接上发出一个 FIN 包。
第二十讲
我们可以使用 fgets 方法等待标准输入,但是一旦这样做,就没有办法在套接字有数据的时候读出数据;我们也可以使用 read 方法等待套接字有数据返回,但是这样做,也没有办法在标准输入有数据的情况下,读入数据并发送给对方。I/O 多路复用的设计初衷就是解决这样的场景。我们可以把标准输入、套接字等都看做 I/O 的一路,多路复用的意思,就是在任何一路 I/O 有“事件”发生的情况下,通知应用程序去处理相应的 I/O 事件,这样我们的程序就变成了“多面手”,在同一时刻仿佛可以处理多个 I/O 事件。select所支持的文件描述符上线只有1024个
你认为 select 函数里一定需要传入描述字基数这个值么?
需要设置。int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
函数select检测相当于遍历三个 fd_set,需要知道数组的上限
第二十一讲
poll 突破了select对文件描述符的限制
1 | int poll(struct pollfd *fds, unsigned long nfds, int timeout); |
pollfd 数组:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16struct pollfd {
int fd; /* file descriptor */
short events; /* events to look for POLLIN POOLOUT*/
short revents; /* events returned */
};
其中对应的事件:
如果我们不想对某个 pollfd 结构进行事件检测,可以把它对应的 pollfd 结构的 fd 成员设置成一个负值。这样,poll 函数将忽略这样的 events 事件,检测完成以后,所对应的“returned events”的成员值也将设置为 0。在 poll 函数里,我们可以控制 pollfd 结构的数组大小,这意味着我们可以突破原来 select 函数最大描述符的限制,在这种情况下,应用程序调用者需要分配 pollfd 数组并通知 poll 函数该数组的大小。
第二十二讲
read / write:
非阻塞读操作:如果套接字对应的接收缓冲区没有数据可读,在非阻塞情况下 read 调用会立即返回,一般返回 EWOULDBLOCK 或 EAGAIN 出错信息
非阻塞写操作:在非阻塞 I/O 的情况下,如果套接字的发送缓冲区已达到了极限,不能容纳更多的字节,那么操作系统内核会尽最大可能从应用程序拷贝数据到发送缓冲区中,并立即从 write 等函数调用中返回已拷贝的字节数
accept:
当 accept 和 I/O 多路复用 select、poll 等一起配合使用时,如果在监听套接字上触发事件,说明有连接建立完成,此时调用 accept 肯定可以返回已连接套接字。但是总有例外
1 | //客户端 |
这里的休眠时间非常关键,这样,在监听套接字上有可读事件发生时,并没有马上调用 accept。由于客户端发生了 RST 分节,该连接被接收端内核从自己的已完成队列中删除了,此时再调用 accept,由于没有已完成连接(假设没有其他已完成连接),accept 一直阻塞,更为严重的是,该线程再也没有机会对其他 I/O 事件进行分发,相当于该服务器无法对其他 I/O 进行服务。如果我们将监听套接字设为非阻塞,上述的情形就不会再发生。只不过对于 accept 的返回值,需要正确地处理各种看似异常的错误,例如忽略 EWOULDBLOCK、EAGAIN 等。
connect:
非阻塞调用时会立即返回 EINPROGRESS 错误,连接后会返回 EISCONN 错误
1 | while(1) { |
第二十三讲
1 | struct epoll_event { |
水平触发(level-trggered)
- 只要文件描述符关联的读内核缓冲区非空,有数据可以读取,就一直发出可读信号进行通知,
- 当文件描述符关联的内核写缓冲区不满,有空间可以写入,就一直发出可写信号进行通知
边缘触发(edge-triggered)
- 当文件描述符关联的读内核缓冲区由空转化为非空的时候,则发出可读信号进行通知,
- 当文件描述符关联的内核写缓冲区由满转化为不满的时候,则发出可写信号进行通知
在linux下,如果用边缘触发同时注册了读和写,当读触发的时候,内核向用户返回fd的时候同时会检查fd是否符合可写的条件(有空间容纳待写入的数据),如果满足可写的条件,同时会加上EPOLLOUT标记。
第三十讲
无论是阻塞 I/O,还是阻塞 I/O,和基于非阻塞 I/O 的多路复用都是同步调用技术。为什么这么说呢?因为同步调用、异步调用的说法,是对于获取数据的过程而言的,前面几种最后获取数据的 read 操作调用,都是同步的,在 read 调用时,内核将数据从内核空间拷贝到应用程序空间,这个过程是在 read 函数中同步进行的,如果内核实现的拷贝效率很差,read 调用就会在这个同步过程中消耗比较长的时间
而真正的异步调用则不用担心这个问题,我们接下来就来介绍第四种 I/O 技术,当我们发起 io_uring之后,就立即返回,内核自动将数据从内核空间拷贝到应用程序空间,这个拷贝过程是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。