UNIX网络编程卷I

Posted by FridayLi on April 8, 2020

卷1: 套接字联网API(第三版)

第1章 简介

  1. 客户和服务器通常是用户进程,而TCP和IP协议通常是内核中协议栈的一部分。
  2. 如果数据量很大, 我们就不能确保一次read调用能返回服务器的整个应答。因此从TCP套接字读取数据时,我们总是需要把read编写在某个循环中, 当read返回0(表明对端关闭连接)或负值(表明发生错误)时终止循环。这里重要的概念是TCP本身并不提供记录结束标志:如果应用程序需要确定记录的边界,它就要自己去实现。
  3. 通常情况下, 服务器进程在accept调用中被投入睡眠,等待某个客户连接的到达并被内核接受。TCP连接使用三路握手来建立连接。握手完毕时accept返回, 其返回值是一个称为已连接描述符(connected descriptor)的新描述符。该描述符用于和新近连接的那个客户通信。accept为每个连接到本服务器的客户返回一个新描述符。
  4. 网络应用绕过传输层直接使用IPv4或IPv6是可能的。这就是所谓的原始套接字(raw socket)
  5. 对于网际协议, OSI模型的顶上三层协议几乎没有区别, 被合并成一层, 称为网络层。套接字提供的是从OSI模型的顶上三层进入传输层的接口。顶上三层通常构成所谓的用户进程,底下四层却通常作为操作系统内核的一部分提供。描述

  6. POSIX : 可移植操作系统接口

第2章 传输层: TCP、UDP和SCTP

  1. UDP 不保证UDP数据报会到达其最终目的地,不保证各个数据报的先后顺序跨网络后保持不变,也不保证每个数据报只到达一次。
  2. 每个UDP数据报都有一个长度,而TCP是一个字节流协议,没有任何记录边界。
  3. TCP 也不能被描述成是100%可靠的协议,它提供的是数据的可靠递送或故障的可靠通知。
  4. TCP连接是全双工(full-duplex)的。这意味着在一个给定的连接上应用可以在任何时刻在进出两个方向上既发送数据又接收数据。
  5. 三路握手:
    • 客户通过调用connect发起主动打开。这导致客户TCP发送一个SYN分节,它告诉服务器客户将在(待建立的)连接中发送的数据的初始序列号。
    • 服务器必须确认(ACK)客户的SYN, 同时自己也得发送一个SYN分节,它含有服务器将在同一连接中发送的数据的初始序列号。
    • 客户必须确认(ACK)服务器的SYN
  6. 四次挥手
    • 某个应用进程首先调用close, 我们称该端执行主动关闭(active close)。该端的TCP发送一个FIN分节,表示数据发送完毕。
    • 接收到这个FIN的对端执行被动关闭(passive close)。这个FIN由TCP确认(ACK)。它的接收也作为一个文件结束符传递给接收端应用进程,因为FIN的接收意味着接收端应用进程在相应连接上再无额外数据可接收。
    • 一段时间后, 接收到这个文件结束符的应用进程将调用close关闭它的套接字。这导致它的TCP也发送一个FIN。在步骤2和步骤3之间,从执行被动关闭一端向执行主动关闭一端流动数据是可能的。这称为半关闭(half-close)
    • 接收这个最终FIN的原发送端TCP确认这个FIN。
  7. TCP 为一个连接定义了11种状态。描述

  8. 一个TCP连接的套接字对(socket pair)是一个定义该连接的两个端点的四元组:本地IP地址、本地TCP端口号、外地IP地址、外地TCP端口号。
  9. 从写一个TCP套接字的write调用成功返回仅仅表示我们可以重新使用原先的应用进程缓冲区,并不表明对端的TCP或应用已接收到数据。TCP必须为已发送的数据保留一个副本, 知道它被对端确认为止。

第3章 套接字编程简介

  1. 从进程到内核传递套接字地址结构的4个套接字函数(bind、connect、sendto和sendmsg);从内核到进程传递套接字地址结构的5个套接字函数分别是accept、recvfrom、recvmsg、getpeername和getsockname.
  2. IPv6的地址族是AF_INET6, 而IPv4的地址族是AF_INET。

第4章 基本TCP套接字编程

  1. connect 函数导致当前套接字从CLOSED状态转移到SYNC_SENT状态, 若成功则再转移到ESTABLISHEND状态。若connect失败则该套接字不可再用,必须关闭,我们不能对这样的套接字再次调用connect函数。 注: 根据posix规范:

    If connect() fails, the state of the socket is unspecified. Conforming applications should close the file descriptor and create a new socket before attempting to reconnect.

  2. 当socket函数创建一个套接字时, 它被假设为一个主动套接字,也就是说,它是一个将调用connect发起连接的客户端套接字。listen函数把一个未连接的套接字转换成一个被动套接字,指示内核应接受指向该套接字的连接请求。(注: 同一个端口下可以有多个主动套接字, 但只能有一个被动套接字)
  3. 内核为任何一个给定的监听套接字维护两个队列:未完成队列和已完成队列。当进程调用accept时,已完成连接队列中的头项将返回给进程。如果该队列为空,那么进程将被投入睡眠,直到TCP在该队列中放入一项才唤醒它。 listen函数的backlog参数层被规定为这两个队列总和的最大值。描述
  4. 当一个客户SYN到达时, 若这些队列是满的,TCP就忽略该分节,也就是不发送RST。这么做是因为:这种情况是暂时的,客户TCP将重发SYN,期望不久就能在这些队列中找到可用空间。客户无法区别响应SYN的RST究竟意味着“该端口没有服务器在监听”,还是“该端口有服务器在监听, 不过它的队列满了”
  5. 在三路握手完成之后,但在服务器调用accpet之前到达的数据应由服务器TCP排队,最大数据量为相应已连接套接字的接收缓冲区。
  6. accept 函数由TCP服务器调用,用于从已完成连接队列头返回下一个已完成连接。如果已完成连接队列为空,那么进程被投入睡眠。
  7. 理解fork最困难之处在于调用它一次,它却返回两次。它在调用进程(父进程)中返回一次,返回值是新派生进程(子进程)的进程ID号。在子进程又返回一次,返回值是0.
  8. 父进程中调用fork之前打开的所有描述符在fork返回之后由子进程分享。通常情况下, 子进程接着读写这个已经连接的套接字,父进程则关闭这个套接字。进程终止处理的部分工作就是关闭所有由内核打开的描述符。但我们必须知道每个文件或套接字都有一个引用计数,初始值为1, fork返回后, 同一个描述符在父子进程中共享(复制),因此引用计数变为2, 父进程关闭已连接的套接字时,只是把相应的引用计数从2减为1,该套接字真正的清理和资源释放要等到其引用计数值到达0时才发生。也就是要等到子进程关闭。 描述
  9. 如果我们确实想在某个TCP连接上发送一个FIN,那么可以改用shutdown函数, 来代替close。
  10. 大多数TCP服务器是并发的, 大多数UDP服务器却是迭代的。

第5章 TCP 客户/服务器程序示例

  1. 用vi/vim 编辑器编辑并保存最后一行不是以换行符结尾的文本文件时, vi同样会给最后一行添加一个换行符。
  2. 客户接收到三路握手的第二个分节时, connect返回,而服务器要直到接收到三路握手的第三个分节才返回,即在connect返回之后再过一半RTT才返回。
  3. 在服务器子进程终止时, 给父进程发送一个SIGCHLD信号。该信号默认行为是被忽略, 如果父进程未加处理,那么子进程会进入僵死状态。
  4. 有两个信号不能被捕获, 他们是SIGKILL 和 SIGSTOP
  5. UNINX 信号默认是不排队的
  6. 适用于慢系统调用的基本规则是:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个EINTR错误。(Python的accept会自动重试的)
  7. waitpid (-1) : -1表示等待第一个终止的子进程。 如果没有子进程了, 则立即返回。建立一个信号处理函数并在其中调用wait并不足以防止出现僵死进程,因为UNIX信号是不排队的,所以有可能同时过来5个信号, 但信号处理函数只执行了一次。
  8. 写一个已经接收了FIN的套接字不成问题,但是写一个已接收了RST的套接字则是一个错误。

第6章 I/O 复用: select 和 poll函数

  1. 进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪,它就通知进程。这个能力称为I/O复用。
  2. 进程把一个套接字设置成非阻塞是在通知内核,当所请求的I/O操作非得把本进程投入睡眠才能完成时, 不要睡眠,而是返回一个错误。
  3. 当一个进程对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询(polling)。
  4. 有了I/O 复用,我们可以调用select或poll, 阻塞在这两个系统调用的某一个之上,而不是阻塞在真正的I/O系统调用上。
  5. 阻塞式I/O模型、非阻塞式I/O模型、I/O复用模型和信号驱动式I/O模型都是同步I/O模型, 因为其中真正的I/O操作(recvfrom)将阻塞进程。只有异步I/O模型与POSIX定义的异步I/O相匹配。
  6. select函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。这里的描述符不局限于套接字,任何描述符都可以用select。
  7. 混合使用select和stdio被认为是非常容易犯错误的, select不知道stdio使用了缓冲区, 多于多个输入可能只触发一次。
  8. 使用shutdown可以不管引用计数就激发TCP的正常连接终止序列(close是将引用计数减一)
  9. 当一个服务器在处理多个客户时,它绝对不能阻塞于只与单个客户相关的某个函数调用。否则可能导致服务器被挂起,拒绝为所有其他客户提供服务。这就是所谓的拒绝服务型攻击。
  10. 描述

第8章 基本UDP套接字编程

  1. 使用UDP编写的一些常见的应用程序有:DNS、NFS(网络文件系统)、SNMP(简单网络管理协议)。
  2. UDP客户不与服务器建立连接,而是只管使用sendto函数给服务器发送数据报,服务器不接受来自客户的连接,而是只管调用recvfrom函数,等待来自某个客户的数据到达,recvfrom将与所接收的数据报一道返回客户的协议地址。
  3. 写一个长度为0的数据报是可行的。在UDP情况下,这会形成一个只包含一个IP首部和一个8字节UDP首部而没有数据的IP数据报。recvfrom返回0是可接受的:它并不像TCP套接字上read返回0那样标识对端已关闭连接(UDP没有连接)
  4. 一般来说,大多数TCP服务器时并发的, 而大多数UDP服务器是迭代的。
  5. UDP 的connect函数没有三路握手过程,内核只是检查是否存在立即可知的错误(比如目的地不可达)
  6. UDP没有流量控制而且是不可靠的。

第13章 守护进程和inetd超级服务器

  1. 守护进程(daemon)是在后台运行且不与任何控制终端关联的进程。守护进程没有控制终端通常源于它们是由系统初始化脚本脚本启动。然而守护进程也可能从某个终端由用户在shell提示符下键入命令启动, 这样的守护进程必须亲自脱离与控制终端的关联,从而避免与作业控制、终端会话管理、终端产生信号等发生任何不期望的交互,也可以避免在后台运行的守护进程非预期的输出到终端。
  2. 实现守护进程:daemon_init: 首先调用fork,然后终止父进程,留下子进程继续执行。setsid是一个POSIX函数,用于创建一个新的会话。再次fork的目的是确保本守护进程将来即使打开了一个终端设备, 也不会自动获得控制终端。必须忽略SIGHUP信号,因为当会话头进程(即首次fork产生的子进程)终止时,其会话中的左右进程(再次fork产生的子进程)都收到SIGHUP信号。
  3. 守护进程产生的所有输出通道通过调用syslog函数发送给syslogd守护进程
  4. 许多Unix服务器由inetd守护进程启动。它处理全部守护进程化所需的步骤,当启动真正的服务器时,套接字已在标准输入、标准输出、和标准错误输出上打开。

第16章 非阻塞式I/O

  1. 对于非阻塞的套接字,如果输入操作不能被满足(对于TCP套接字即至少有一个字节的数据可读,对于UDP套接字即有一个完整的数据报可读),相应调用将立即返回一个EWOULDBLOCK错误。输出操作、accept等也是返回EWOULDBLOCK错误
  2. 如果对一个非阻塞的TCP套接字调用connect, 并且连接不能立即建立,那么连接的建立能照样发起(三路握手),不过会返回一个EINPROGRESS错误。另外需要注意,有些连接可以立即建立,通常发生在服务器和客户处于同一个主机的情况下。因此,即使对于一个非阻塞的connect,我们也得预备connect成功返回的情况发生。
  3. 每当我们发现需要使用非阻塞式I/O时, 更简单的办法通常是把应用程序任务划分成多个进程或多个线程。单线程处理各个socket的缓冲器太复杂。
  4. 非阻塞版本几乎比select加阻塞式I/O版本快出一倍。fork版本比非阻塞版本稍慢,然而考虑到非阻塞版本代码相比fork版本代码的复杂性,我们推荐简单的多的fork版本。
  5. 一个TCP套接字变为可写的条件是:其发送缓冲区中有可用空间,并且该套接字已建立连接。一个TCP套接字上发生某个错误时,这个待处理错误总是导致该套接字变为既可读又可写。
  6. select通常结合非阻塞式I/O一起使用,以便判断描述符何时可读可写。

    第26章 线程

  7. 多进程有两个问题:一、fork是昂贵的。二、父子进程间消息的传递需要进程间通信(IPC)机制。
  8. 线程的创建可能比进程的创建快 10-100倍
  9. 创建新线程并不影响已打开描述符的引用计数,这一点不同于fork。

    第30章 客户/服务器程序设计范式

  10. 当开发一个Unix服务器程序时, 我们有如下选择: *迭代服务器 *并发服务器–fork进程 *select I/O 复用 *并发服务器——线程 *预先派生子进程 *预先派生子线程

  11. 为每个客户端现场fork一个子进程比较耗费CPU时间。
  12. 如果某个时刻客户数恰好等于子进程总数, 那么新到的客户将被忽略。但其实这些客户并未被完全忽略, 内核将为每个新到的客户完成三路握手,直到达到相应套接字上listen调用的backlog数为止, 然后在服务器调用accep时把这些已完成的连接传递给它。对这些客户端来说, 尽管它的connect调用立刻返回, 但是它的第一个请求可能是在一段时间之后才被服务器处理。
  13. 多个进程在同一个socket调用accept阻塞时, 当一个客户连接到达, N个子进程均被唤醒,但只有最先运行的子进程获得客户端连接, 其余N-1个继续睡眠, 这就是惊群问题。
  14. 当可用子进程阻塞在accept调用上时, 内核调度算法把各个连接均匀地散步到各个子进程。
  15. 当多个进程在引用同一个套接字的描述符上调用select时就会发生冲突,因为在socket结构中为存放本套接字就绪之时应该唤醒哪些进程而分配的仅仅是一个进程ID的空间。如果多个进程在等待同一个套接字, 内核必须唤醒的是阻塞在select调用的所有进程。
  16. 如果有多个进程阻塞在引用同一个实体的描述符上,那么最好直接阻塞在诸如accept之类的函数而不是select之中。
  17. 当系统负载较轻时, 每来一个客户请求现场派生一个子进程为之服务的传统并发服务器模型就够了
  18. 让所有子进程或线程自行调用accpet通常比让父进程或者主线程独自调用accept并把描述符传递给子进程或者子线程来得简单和快速。
  19. 如果accept客户连接的服务器调用fork, 那么fork一个单线程的进程可能快于fork一个多线程的进程。

书中示例的Python实现

书中示例的Python实现参见Github