简介

1.1

  • 要编写通过计算机网络通信的程序,首先要确定这些程序相互通信所用的协议(protocol)
  • 在深入设计一个协议的细节之前,应该从高层次决断通信由哪个程序发起以及响应在何时产生

  • 举例来说,一般认为Web服务器程序是一个长时间运行的程序(即所谓的守护程序,daemon)

  • 同一网络应用的客户和服务器无需处于同一个局域网(local area network, LAN)
  • 两个局域网是使用路由器(router)连接到广域网(wide area network, WAN)
  • 路由器是广域网的架构设备。当今最大的广域网是因特网(Internet)
  • 许多公司也构建自己的广域网,而这些私有的广域网既可以连接到因特网,也可以不连接到因特网

1.2

  • socket函数创建一个网际(AF_INET)字节流(SOCK_STREAM)套接字,它是TCP套接字的花哨名字。
  • 该函数返回一个小整数描述符,以后的所有函数调用(例如connect和read)就用该描述符来标识这个套接字

  • 后面,将遇到术语套接字(socket)的许多不同用法。
  • 首先,正在使用的API称为套接字API(sockets API),socket函数就是套接字API的一部分
  • TCP套接字,它是TCP端点(TCP endpoint)的同义词
  • 如果socket函数调用失败,我们就调用自己的err_sys函数,放弃程序的运行
  • err_sys函数输出我们作为参数提供的出错消息以及所发生的系统错误的描述

1.3

  • connect函数应用于一个TCP套接字时,将与由它的第二个参数只想的套接字地址结构指定的服务器建立一个TCP连接。
  • 该套接字地址结构的长度也必须作为该函数的第三个参数指定,对于网际套接字地址结构,我们总是使用C语言的sizeof操作符由编译器来计算这个长度

1.4

  • 使用read函数读取服务器的应用,并用标准的I/O函数fputs输出结果。
  • 使用TCP时必须小心,因为TCP是一个没有记录边界的字节流协议。

  • 计算机网络各层对等实体间交换的单位信息,称为协议数据单元(protocol data unit, PDU)
  • 分节(segment)就是对应于TCP传输层的PDU
  • 按照协议与服务之间的关系,除了最底层(物理层)外,每层的PDU通过由紧邻下层提供给本层的服务接口,作为下层的服务数据单元(service data unit, SDU)传递给下层,并由下层间接完成本层的PDU交换
  • 应用层实体(例如客户和服务器进程)间交换的PDU称为应用数据(application data),
  • 其中在TCP应用进程之间交换的是没有长度限制的单个双向字节流
  • 在UDP应用进程之间交换的是其长度不超过UDP发送缓冲区大小的单个记录(record)
  • 在SCTP应用进程之间交换的是没有总长度限制的单个或多个记录流

  • 传输层实体(例如对应某个端口的传输层协议代码的一次运行)间交换的PDU称为消息(message),其中TCP的PDU特称为分节(segment)。
  • 消息或分节的长度是有限的。
  • 在TCP传输层中,发送端TCP把来自应用进程的字节流数据(即由应用进程通过一次次输出操作写出到发送端TCP套接字中的数据)按顺序分割后封装在各个分节中传送给接收端TCP。
  • 其中,每个分节所封装的数据既可能是发送端应用进程单次输出操作的结果,也可能是连续数次输出操作的结果,而且每个分节所封装的单次输出操作的结果或者首尾两次操作的结果既可能是完整的,也可能是不完整的,具体取决于可在连接建立阶段由对端通告的最大分节大小(maximum segment size, MMS)以及外出接口的最大传输单元(maximum transmission unit, MTU)或外出路径的路径MTU(如果网络层具有路径MTU发现功能,例如IPv6)

  • 通常服务器返回包含所有26个字节的单个分节,但是如果数据量很大,我们就不能确保一次read调用能返回服务器的整个应答。
  • 因此从TCP套接字读取数据时,我们总是需要把read编写在某个循环中,当read返回0(表明对端关闭连接)或负值(表明发生错误)时终止循环

1.5

  • 网络层实体间交换的PDU,称为IP数据报(IP datagram),其长度有限:IPv4数据报最大65535字节,IPv6数据包最大65575字节。
  • 发送端IP把来自传输层的消息(或TCP分节)整个封装在IP数据报中传送

  • 链路层实体间交换的PDU,称为帧(frame),其长度取决于具体的接口。
  • IP数据报由IP首部和所承载的传输层数据(即网络层的SDU)构成
  • 过长的IP数据报无法封装在单个帧中,需要先对其SDU进行分片(fragmentation),再把分成各个片段(fragment)冠以新的IP首部封装到多个帧中

  • 在一个IP数据报从源端到目的端的传送过程中,分片操作既可能发生在源端,也可能发生在途中,而其逆操作,即重组(reassembly)一般只发生在目的端
  • SCTP为了传送过长的记录采取了类似的分片和重组措施
  • TCP/IP协议族为了提高效率会尽可能避免IP的分片/重组操作;TCP根据MSS和MTU限定每个分节的大小以及SCTP根据MTU分片/重组过长的记录都是这个目的
  • 不论是否分片,都由IP作为链路层的SDU传入链路层,并由链路层封装在帧中的数据称为,分组(packet, 俗称包)
  • 可见,一个分组既可能是一个完整的IP数据报,也可能是某个IP数据报的SDU的一个片段被冠以新的IP首部后的结果

  • 另外,
    • 本书讨论的MSS是应用层(TCP)与传输层之间的接口属性,
    • MTU则是网络层和链路层之间的接口属性

1.6

  • 任何现实世界的程序,都必须检查每个函数调用是否返回错误。

  • 既然发生错误时终止程序的运行是普遍的情况,我们可以通过定义包裹函数(wrapper function)来缩短程序。
  • 每个包括函数完成实际的函数调用,检查返回值,并在发生错误时终止进程
  • 我们约定的包裹函数是实际函数名的首字母大写形式,例如
    • sockfd = Socket(AF_INET, SOCK_STREAM, 0);
  • 其中,函数Socket是函数socket的包裹函数
    1
    2
    3
    4
    5
    6
    7
    
      int Socket (int family, int type, int protocol) {
          int n;
          if ((n = socket(family, type, protocol)) < 0) {
              err_sys("socket error");
          }
          return n;
      }
    
  • 这些包裹函数不见得多节省代码量,但是当我们讨论线程时,将会发现线程函数遇到错误时,并不设置标准Unix的errno变量,而是把errno的值作为函数返回值返回调用者。
  • 这意味着每次调用以pthread_开头的某个函数时,我们必须分配一个变量来存放函数的返回值,以便在调用err_sys前把errno变量设置成该值。

1.7

  • 通过填写一个网际套接字地址结构并调用bind函数,服务器的众所周知端口被捆绑到所创建的套接字
  • 指定IP地址为INADDR_ANY,这样要是服务器主机有多个网络接口,服务器就可以在任意网络接口上接收客户端连接

  • 调用listen函数把该套接字转换成一个监听套接字,这样来自客户的外来连接就可在该套接字上由内核接受
  • socket, bind和listen这3个调用步骤是任何TCP服务器准备所谓的监听描述符(listening descriptor)的正常步骤
  • 常值LISTENQ在unp.h头文件中定义。它指定系统内核允许在这个监听描述符上排队的最大客户连接数

1.8

  • 通常情况下,服务器进程在accept调用中被投入睡眠,等待某个客户连接的到达并被内核接受
  • TCP连接使用所谓的三路握手(three-way handshake)来建立连接。
    • 三次握手在socket API中内部实现,调用该接口时,不需要关心(Unix system programming,line:1807)
  • 握手完毕时accept返回,其返回值是一个称为已连接描述符(connected descriptor)的新描述符
  • 该描述符用于与新近连接的那个客户端通信。
  • accept为每个连接到本服务器的客户端返回一个新描述符

1.9

  • 服务器通过调用close关闭与客户的连接。
  • 该调用引发正常的TCP连接终止序列:每个方向上发送一个FIN,每个FIN又由各自的对端确认

  • 总结
    • 如果服务器需要用较多时间服务每个客户,那么必须以某种方式重叠对各个客户的服务,这种服务器被称为迭代服务器(iterative server)。因为对于每个客户它都迭代执行一次
    • 同时能处理多个客户的并发服务器(concurrent server)有多种编写技术
      • 最简单的技术是调用Unix的fork函数,为每个客户创建一个子进程
      • 其他技术包括使用线程代替fork,或在服务器启动时预先fork一定数量的子进程

2.0

  • 描述一个网络中各个协议层的常用方法是使用国际标准化组织(International Organization for Standardization, ISO)的计算机通信开发系统互连(open systems interconnection, OSI)模型。
  • 这是一个七层模型:
    • 应用程
    • 表示层
    • 会话层
    • 传输层
    • 网络层
    • 数据链路层
    • 物理层
  • 我们认为OSI模型的底下两层是随系统提供的设备驱动程序和网络硬件。
  • 通常情况下,除需要知道数据链路的某些特性外,不必太关心这两层的具体情况

  • 网络层,由IPv4和IPv6这两个协议处理

  • 可以选择的传输层有TCP或UDP
  • TCP与UDP,之间留有缝隙,表明网络应用绕过传输层直接使用IPv4和IPv6是有可能的,这就是所谓的原始套接字(raw socket)

  • OSI模型的顶上三层被合并成一层,称为应用层。
  • 这就是Web客户(浏览器),Telnet客户,Web服务器,FTP服务器和其他我们在使用的网络应用所在的层
  • 对于网际协议,OSI模型的顶上三层协议几乎没有区别

  • 本书讲述的套接字编程接口是从顶上三层(网际协议的应用程)进入传输层的接口
  • 本书的焦点是:如何使用套接字编写使用TCP或UDP的网络应用程序。

  • 为什么套接字提供的是从OSI模型的顶上三层进入传输层的接口?
  • 这样设计有两个理由
    • 一,顶上三层处理具体网络应用的所有细节,却对通信细节了解很少;底下四层对具体网络应用了解不多,却处理所有的通信细节:发送数据,等待确认,给无序到达的数据排序,计算并验证校验和,等等
    • 二,顶上三层通常构成所谓的用户进程(user process),底下四层却通常作为操作系统内核的一部分提供。Unix与其他现代操作系统都提供分隔用户进程和内核的机制。
    • 由此可见,第四层和第五层之间的接口是构建API的自然位置

2.1

  • POSIX(可移植操作系统接口),是Portable Operating System Inteface的首字母缩写。
  • 它并不是单个标准,而是由电气与电子工程学会(the Institute for Electrical and Electronics Engineers Inc)即IEEE开发的一系列标准
  • 具体可查看http://www.pasc.org/standing/sdll.html

传输层

1.1

  • TCP提供客户与服务器之间的连接。
  • TCP客户先与某个给定服务器建立一个连接,再跨该连接与那个服务器交换数据,然后终止这个连接

  • 三路握手
    • 服务器必须准备好接受外来的连接。
    • 这通常通过调用socket,bind和listen三个函数来完成,我们称之为被动打开(passive open)
    • 客户通过调用connect发起主动打开(active open)
  • 四次挥手
    • 某个应用进程首先调用close,我们称该端执行主动关闭(active close)
    • 接收到这个FIN的对端执行被动关闭(passive close)

1.2

  • TIME_WAIT状态有两个存在的理由:
    • 可靠地实现TCP全双工连接的终止
    • 允许老的重复分节在网络中消逝
  • 套接字对
    • 一个TCP连接的套接字对(socket pair),是一个定义该连接的两个端点的四元组
    • 本地IP地址,本地TCP端口号,外地IP地址,外地TCP端口号
  • 套接字对,唯一标识一个网络上的每个TCP连接
  • 标识每个端点的两个值(IP地址和端口号)通常称为一个,套接字

套接字编程

1.1

  • IPv4套接字地址结构,通常也称为“网际套接字地址结构”,它以sockaddr_in命名,定义在<netinet/in.h>头文件中

  • 通用套接字地址结构
    • 当作为一个参数传递进任何套接字函数时,套接字地址结构总是以引用形式(也就是以指向该结构的指针)来传递。
  • 在如何声明所传递指针的数据类型上存在一个问题。有了ANSI C后解决办法很简单:void *是通用的指针类型
  • 在1982年采取的办法是在<sys/socket.h>头文件中定义一个通用的套接字地址结构

1.2

  • 前面提到过,当往一个套接字函数传递一个套接字地址结构时,该结构总是以引用形式来传递,也就是说传递的是指向该结构的一个指针。
  • 该结构的长度也作为一个参数来传递,不过其传递方式取决于该结构的传递方向:是从进程到内核,还是从内核到进程
    • 从进程到内核传递套接字地址结构的函数有三个:bind,connect和sendto。指针和指针所指内容的大小都传递给内核,这样内核知道到底需从进程复制多少数据进来
    • 从内核到进程传递套接字地址结构的函数有四个:accept,recvfrom,getsockname和getpeername。把套接字地址结构大小这个参数从一个整数改为指向某个整数变量的指针,其原因在于:
      • 当函数被调用时,结构大小是一个值(value),它告诉内核该结构的大小,这样内核在写该结构时不至于越界
      • 当函数返回时,结构大小又是一个结果(result),它告诉进程,内核在该结构中究竟存储了多少信息
      • 这种类型的参数称为“值-结果(value-result)”参数

1.3

  • 考虑一个16位整数,它由2个字节组成。
  • 内存中存储这两个字节有两种方法
    • 一种是将低序字节存储在起始地址,这称为小端(little-endian)字节序
    • 另一种方法是将高序字节存储在起始地址,这称为大端(big-endian)字节序
  • 遗憾的是,这两种字节序之间没有标准可循,两种格式都有系统使用。
  • 我们把某个给定系统所用的字节序称为主机字节序(host byte order)

  • 网络协议指定一个网络字节序(network byte order)
  • 在每个TCP分节中都有16位的端口号和32位的IPv4地址。发送协议栈和接收协议栈必须就这些多字节字段各个字节的传送顺序达成一致。
  • 网际协议使用大端字节序来传送这些多字节整数

1.4

  • inet_aton, inet_addr和inet_ntoa在 点分十进制数串 与 它长度为32位的网络字节序二进制值 间转换IPv4地址

  • inet_ntoa函数将一个32位的网络字节序二进制IPv4地址转换成响应的点分十进制数串
  • 由该函数的返回值指向的字符串驻留在静态内存中。这意味着该函数是不可重入的
  • 该函数以一个结构而不是以指向该结构的一个指针作为其参数
    • “函数以结构为参数是罕见的,更常见的是以指向结构的指针为参数”

1.5

  • 字节流套接字(例如TCP套接字)上的 read 和 write 函数所表现的行为不同于通常的文件I/O。
  • 字节流套接字上调用 read 和 write 输入或输出的字节数可能比请求的数量少,然而这不是出错的状态。
  • 这个现象的原因在于内核中用于套接字的缓冲区可能已达到了极限。此时所需的是调用者再次调用 read 或 write函数,以输出或输出剩余的字节

基本TCP套接字编程

1.1

  • socket函数在成功时返回一个小的非负整数值,它与文件描述符类似,我们把它称为套接字描述符(socket descriptor),简称sockfd。
  • 为了得到这个套接字描述符,我们只是指定了协议族(IPv4,IPv6或Unix)和套接字类型(字节流,数据报或原始套接字)。
  • 我们并没有指定本地协议地址和远程协议地址

  • 对比 AF_XXX 和 PF_XXX
    • AF_ 前缀表示地址族,PF_ 前缀表示协议族
  • TCP客户用connect函数来建立与TCP服务器的连接
  • 客户在调用函数connect前不必非得调用bind函数,因为如果需要的话,内核会确定源IP地址,并选择一个临时端口作为源端口

  • bind函数把一个本地协议地址赋予一个套接字。对于网际网协议,协议地址是32为的IPv4地址或128位的Ipv6地址与16位的TCP或UDP端口号的组合
  • 服务器在启动时捆绑它们的众所周知端口。如果一个TCP客户或服务器未曾调用bind捆绑一个端口,当调用connect或listen时,内核就要为相应的套接字选择一个临时端口。
  • 让内核来选择临时端口对于TCP客户来说是正常的,除非应用需要一个预留端口;然而对于TCP服务器来说却极为罕见,因为服务器是通过它们的众所周知端口被大家认识的
    • 这个规则的例外是远程过程调用(Remote Procedure Call, RPC)服务器,它们通常就由内核为它们的监听套接字选择一个临时端口,而该端口随后通过RPC端口映射器进行注册。
    • 客户在connect这些服务器之间,必须与端口映射器联系以获取它们的临时端口,这种秦广也适用于使用UDP的RPC服务器
  • 捆绑(binding)操作涉及三个对象:套接字,地址及端口。
    • 其中套接字是捆绑的主体,地址和端口是捆绑在套接字上的客体
  • 如果指定端口号为0,那么内核就在bind被调用时选择一个临时端口。
  • 然而如果指定IP地址为通配地址,那么内核将等到套接字已连接(TCP)或已在套接字上发出数据包(UDP)时才选择一个本地IP地址
  • 对于IPv4来说,通配地址由常值INADDR_ANY来指定,其值一般为0
  • 如果让内核来为套接字选择一个临时端口号,那么必须注意,函数bind并不返回所选择的值。为了得到内核所选择的这个临时值,必须调用函数getsockname来返回协议地址

  • 到达(arriving)和接收(received)
    • 这两个修饰词,它们具有相同的含义,只是视角不同而已
    • 譬如说一个分组的到达接口和接收接口指的是同一个接口,前者在接收主机以外看待这个接口,后者在接收主机以内看待这个接口

1.2

  • listen函数仅由TCP服务器调用,它做两件事情
    • 当socket函数创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将调用connect发起连接的客户套接字。listen函数把一个未连接的套接字转换成一个被动套接字,指示内核应接收指向该套接字的连接请求。调用listen导致套接字从CLOSE状态转换到LISTEN状态
    • 第二个参数规定了内核应该为相应套接字排队的最大连接个数
  • 为了理解第二个参数backlog参数,我们必须认识到内核为任何一个给定的监听套接字维护两个队列:
    • 未完成连接队列(incomplete connection queue),每个这样的SYN分节对应其中一项:已由某个客户发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程。这些套接字处于SYN_RCVD状态
    • 已完成连接队列(completed connection queue),每个已完成TCP三路握手过程的客户对应其中一项,这些套接字处于ESTABLISHED状态

1.3

  • accept函数由TCP服务器调用,用于从已完成连接队列队头返回下一个已完成连接。如果已完成连接队列为空,那么进程被投入睡眠(假定套接字为默认的阻塞方式)
  • 声明
    • #include <sys/socket.h>
    • int accept (int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
  • 参数cliaddr和addrlen用来返回已连接的对端进程(客户)的协议地址。addrlen是值-结果参数;调用前,我们将由*addrlen所引用的整数值置为由cliaddr所指的套接字地质结构的长度,返回时,该整数值即为由内核存放在该套接字地址结构内的确切字节数

  • 如果accept成功,那么其返回值是由内核自动生成的一个全新描述符,代表与所返回客户的TCP连接
  • 在讨论accept函数时,我们称它的第一个参数为监听套接字(listening socket)描述符(由socket创建,随后用作bind和listen的第一个参数的描述符),称它的返回值为已连接套接字(connect socket)描述符。
  • 区分这两个套接字非常重要!
    • 一个服务器通常仅仅创建一个监听套接字,它在该服务器的生命期内一直存在。
    • 内核为每个由服务器进程接受的客户连接创建一个已连接套接字(也就是说对于它的TCP三路握手过程已经完成)。当服务器完成对某个给定客户的服务时,相应的已连接套接字就被关闭
  • accept函数最多返回三个值
    • 一个既可能是新套接字描述符也可能是出错指示的整数
    • 客户进程的协议地址(由cliaddr指针所指)以及该地址的大小(由addrlen指针所指)。如果我们对返回客户协议地址不感兴趣,那么可以把cliaddr和addrlen均置为空指针

1.4

  • 在阐述如何编写并发服务器程序之前,我们必须首先介绍一下Unix的fork函数。
  • 该函数(包括有些系统可能提供的它的各种变体),是Unix中派生新进程的唯一方法。

  • 如果以前从未接触过该函数,那么理解fork最困难之处在于调用它一次,它却返回两次。它在调用进程(称为父进程)中返回一次,返回值是新派生进程(称为子进程)的进程ID号;在子进程又返回一次,返回值为0.
  • 因此,返回值本身告知当前进程是子进程还是父进程。

  • fork在子进程返回 0 而不是父进程的 进程ID 的原因在于:
    • 任何子进程只有一个父进程,而且子进程总是可以通过调用 getppid 取得父进程的进程ID。
    • 相反,父进程可以有许多子进程,而且无法获取各个子进程的进程ID。如果父进程想要跟踪所有子进程的进程ID,那么它必须记录每次调用 fork 的返回值
  • 父进程中调用 fork 之前打开的所有描述符在 fork 返回之后由子进程分享。
  • 我们将看到网络服务器利用了这个特性:
    • 父进程调用 accept 之后调用fork。所接受的已连接套接字随后就在父进程与子进程之间共享。
    • 通常情况下,子进程接着读写这个已连接套接字,父进程则关闭这个已连接套接字
  • fork 有两个典型用法
    • 一个进程创建一个自身的副本,这样每个副本都可以在另一个副本执行其他任务的同时处理自己的某个操作。这是网络服务器的典型用法
    • 一个进程想要执行另一个程序。既然创建新进程的唯一办法是调用 fork,该进程于是首先调用 fork 创建一个自身的副本,然后其中一个副本(通常为子进程)调用 exec 把自身替换成新的程序。这是诸如 shell 之类程序的典型用法
  • 存放在硬盘上的可执行程序文件能够被 Unix 执行的唯一方法是:由一个现有进程调用六个 exec 函数中的某一个。(当这六个函数中是哪一个被调用并不重要时,我们往往把它们统称为 exec 函数。)
  • exec 把当前进程映像替换成新的程序文件,而且该新程序通常从 main 函数开始执行。进程ID并不改变。我们称调用 exec 的进程为调用进程(calling process),称新执行的程序为新程序(new program)

  • 这六个 exec 函数之间的区别在于:
    • 待执行的程序文件是由文件名(filename)还是由路径名(pathname)指定;
    • 新程序的参数是一一列出还是由一个指针数组来引用;
    • 把调用进程的环境传递给新程序还是给新程序指定新的环境。
  • 这些函数只在出错时才返回到调用者。否则,控制将被传递给新程序的起始点,通常就是 main 函数
  • 这六个函数,一般来说,只有 execve 是内核中的系统调用,其他五个都是调用 execve 的库函数(91页,关系图)

1.5

  • 当服务一个客户请求可能花费较长时间时,我们并不希望整个服务器被单个客户长期占用,而是希望同时服务多个客户。
  • Unix中编写并发服务器程序最简单的办法就是 fork 一个子进程来服务每个客户

  • 当一个连接建立时,accept 返回,服务器接着调用 fork,然后由子进程服务客户(通过已连接套接字 connfd),父进程则等待另一个连接(通过监听套接字 listenfd)。
  • 既然新的客户由子进程提供服务,父进程就关闭已连接套接字

  • 对一个TCP套接字调用 close 会导致发送一个 FIN,随后是正常的TCP连接终止序列。为什么父进程对 connfd 调用 close 没有终止它与客户的连接呢?
    • 为了便于理解,我们必须知道每个文件或套接字都有一个引用计数。引用计数在文件表项中维护,它是当前打开着的引用该文件或套接字的描述符的个数。
    • socket 返回后与 listenfd 关联的文件表项的引用计数值为1。 accept 返回后与 connfd 关联的文件表项的引用计数也为1。
    • 然后 fork 返回后,这两个描述符就在父进程与子进程间共享(也就是被复制),因此与这两个套接字相关联的文件表项各自的访问计数值均为2.这么一来,当父进程关闭connfd时,它只是把相应的引用计数值从2减为1.
    • 该套接字真正的清理和资源释放要等到其引用计数值到达0时才发生。这会在稍后子进程也关闭connfd时发生。

1.6

  • 通常的Unix close函数也用来关闭套接字,并终止TCP连接

  • close一个TCP套接字的默认行为,是把该套接字标记成已关闭,然后立即返回到调用进程。
  • 该套接字描述符不能再由调用进程使用,也就是说它不能再作为read或write的第一个参数。
  • 然而TCP将尝试发送已排队等待发送到对端的任何数据,发送完毕后发生的是正常的TCP连接终止序列

  • 如果我们确实想在某个TCP连接上发送一个FIN,那么可以改用shutdown函数以代替close
  • 我们还得清楚,如果父进程对每个由 accept 返回的已连接套接字都不调用close,那么并发服务器中将会发生什么?
    • 首先,父进程最终将耗尽可用描述符,因为任何进程再任何时刻可拥有的打开着的描述符通常是有限制的
    • 不过更重要的是,没有一个客户连接会被终止。当子进程关闭已连接套接字时,它的引用计数值由2递减为1且保持为1,因为父进程永不关闭任何已连接套接字。这将妨碍TCP连接终止序列的发生,导致连接一直打开着

1.7

  • getsockname 和 getpeername两个函数,
    • 或者返回与某个套接字关联的本地协议地址(getsockname),
    • 或者返回某个套接字关联的外地协议地址(getpeername)
  • 需要注意的是,这两个函数的最后一个参数都是值-结果参数。这就是说,这两个函数都得装填由localaddr或peeraddr指针所指向的套接字地址结构

  • 需要这两个函数的理由如下:
    • 在一个没有调用bind的TCP客户上,connect成功返回之后,getsockname用于返回由内核赋予该连接的本地IP地址和本地端口号
    • 在以端口号0调用bind(告知内核去选择本地端口号)后,getsockname用于返回由内核赋予的本地端口号
    • getsockname可用于获取某个套接字的地址族
    • 在一个以通配IP地址调用bind的TCP服务器上,与某个客户的连接一旦建立(accept成功返回),getsockname就可以用于返回由内核赋予该连接的本地IP地址。在这样的调用中,套接字描述符必须是已连接套接字的描述符,而不是监听套接字的描述符
    • 当一个服务器是由调用过accept的某个进程通过调用exec执行程序时,它能够获取客户身份的唯一途径便是调用getpeername

TCP客户/服务器程序示例

1.1

  • 信号(signal),就是告知某个进程发生了某个事件的通知,有时也称为软件中断(software interrupt)。
  • 信号通常是异步发生的,也就是说进程预先不知道信号的准确发生时刻

  • 信号可以:
    • 由一个进程发给另一个进程(或自身)
    • 由内核发给某个进程
  • 每个信号都有一个与之关联的处置(disposition),也称为行为(action)。我们通过调用sigaction函数来设定一个信号的处置,并有三种选择
    • 我们可以提供一个函数,只要有特定信号发生它就被调用。这样的函数称为信号处理函数(signal handler),这种行为称为捕获(catching)信号。有两个信号不能被捕获,它们是SIGKILL和SIGSTOP。信号处理函数由信号值这个单一的整数参数来调用,且没有返回值,其原型为void handler(int signo);(信号处理函数,也称为信号处理程序,这是相对于main函数所在的主程序而言的)。对于大多数信号来说,调用sigaction函数并指定信号发生时所调用的函数就是捕获信号所需做的全部工作。
    • 我们可以把某个信号的处置设定为SIG_IGN来忽略(ignore)它。SIGKILL和SIGSTOP这两个信号不能被忽略
    • 我们可以把某个信号的处置设定SIG_DFL来启用它的默认处置。默认处置,通常是收到信号后终止进程,其中某些信号还在当前工作目录产生一个进程的核心映像(core image,也称为内存影像)

1.2

  • 设置僵死(zombie)状态的目的,是维护子进程的信息,以便父进程在以后某个时候获取。这些信息包括子进程的进程ID,终止状态以及资源利用信息(CPU时间,内存使用量等)
  • 如果一个进程终止,而该进程有子进程处于僵死状态,那么它的所有僵死子进程的父进程ID将被重置为1(init进程)。继承这些子进程的init进程将清理它们(也就是说init进程将wait它们,从而去除它们的僵死状态)

  • 我们显然不愿意留存僵死进程。它们占用内核中的空间,最终可能导致我们耗尽进程资源
  • 无论何时我们fork子进程都得wait它们,以防它们变成僵死进程。为此我们建立一个俘获SIGCHLD信号的信号处理函数,在函数体中我们调用wait

1.3

  • 我们用术语慢系统调用(slow system call)描述过accept函数,该术语也适用于那些可能永远阻塞的系统调用。永远阻塞的系统调用是指调用有可能永远无法返回,多数网络支持函数都属于这一类。
  • 一个值得注意的例外是磁盘I/O,它们一般都会返回到调用者(假设没有灾难性的硬件故障)

  • 适用于慢系统调用的基本规则是:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个EINTR错误

  • 本节的目的是示范我们在网络编程时可能会遇到的三种情况
    • 当fork子进程时,必须捕获SIGCHLD信号
    • 当捕获信号时,必须处理被中断的系统调用
    • SIGCHLD的信号处理函数必须正确编写,应使用waitpid函数以免留下僵死进程

1.4

  • 当我们的服务器进程正在运行时,服务器主机被操作员关机将会发生什么?
  • Unix系统关机时,init进程通常先给所有进程发送SIGTERM信号(该信号可被捕获),等待一段固定的时间(往往在5到20秒之间),然后给所有仍在运行的进程发送SIGKILL信号(该信号不能被捕获)。这么做留给所有运行的进程一小段时间来清楚和终止。如果我们不捕获SIGTERM信号并终止,我们的服务器将由SIGKILL信号终止

  • 如果我们忽略SIGTERM信号,我们的服务器将由SIGKILL信号终止。SIGTERM信号的默认处置就是终止进程,因此要是我们不捕获它(也不忽略它),那么起作用的是它的默认处置,我们的服务器将被SIGTERM信号终止,SIGKILL信号不可能再发送给他

1.5

  • 一般来说,我们必须关心在客户和服务器之间进行交换的数据的格式

I/O复用:select和poll函数

1.1

  • 进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪(也就是说输入已准备好被读取,或者描述符已能承接更多的输出),它就通知进程。这个能力称为I/O复用(I/O multiplexing),是由select和poll这两个函数支持的

  • 在介绍select和poll这两个函数之前,我们需要回顾整体,查看Unix下可用的5种I/O模型的基本区别:
    • 阻塞式I/O
    • 非阻塞式I/O
    • I/O复用(select和poll)
    • 信号驱动式I/O(SIGIO)
    • 异步I/O(POSIX的aio_系列函数)
  • 一个输入操作通常包括两个不同的阶段:
    • 等待数据准备好
    • 从内核向进程复制数据
  • 对于一个套接字上的输入操作,
    • 第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区
    • 第二步就是把数据从内核缓冲区复制到应用进程缓冲区
  • 当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询(polling)。
  • 应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往耗费大量CPU时间

1.2

  • 有了I/O复用(I/O multiplexing),我们就可以调用select或poll,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的I/O系统调用上
  • 我们阻塞于select调用,等待数据包套接字变为可读。当select返回套接字可读这一条件时,我们调用recvfrom把所读数据包复制到应用进程缓冲区

  • 我们也可以用信号,让内核在描述符就绪时发送SIGIO信号通知我们。我们称这种模型为信号驱动式I/O(signal-driven I/O)

  • 异步I/O(asynchronous I/O)由POSIX规范定义
  • 这些函数的工作机制是:告诉内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。
  • 这种模型与信号驱动模型主要区别在于
    • 信号驱动式I/O,是由内核通知我们何时可以启动一个I/O操作
    • 而异步I/O模型,是由内核通知我们I/O操作何时完成

1.3

  • POSIX把同步I/O和异步I/O两个术语定义如下:
    • 同步I/O操作(synchronous I/O opetation),导致请求进程阻塞,直到I/O操作完成;
    • 异步I/O操作(asynchronous I/O opetation),不导致请求进程阻塞
  • 根据上述定义,前四种模型:阻塞式I/O模型,非阻塞式I/O模型,I/O复用模型和信号驱动式I/O模型都是同步I/O模型,因为其中真正的I/O操作(recvfrom)将阻塞进程。
  • 只有异步I/O模型与POSIX定义的异步I/O相匹配

1.4

  • select函数,允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的事件后唤醒它
  • 声明:
    • #include <sys/select.h>
    • #include <sys/time.h>
    • int select (int maxfdpl, fd_set *readset, fd_set *writeset, fd_set *execptset, const struct timeval *timeout);
  • 参数timeout,它告知内核等待所指定描述符中的任何一个就绪可花多长时间。其中timeval结构用于指定这段时间的秒数和微秒数
  • 这个参数有以下三种可能:
    • 永远等待下去:仅在有一个描述符准备好I/O时才返回。为此,我们把该参数设置为空指针
    • 等待一段固定时间:在有一个描述符准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数
    • 根本不等待:检查描述符后立即返回,这称为轮询(polling)。为此,该参数必须指向一个timeval结构,而且其中的定时器值(由该结构指定的秒数和微秒数)必须为0.
  • 中间的三个参数readset,writeset和execptset指定我们要让内核测试读,写和异常条件的描述符。目前支持的异常条件只有两个:
    • 某个套接字的外带数据的到达
    • 某个已置为分组模式的伪终端存在可从其主端读取的控制状态信息
  • 如何给这三个参数中的每一个参数指定一个或多个描述符值是一个设计上的问题。select使用描述符集,通常是一个整数数组,其中每个整数中的每一位对应一个描述符
  • select函数的中间三个参数readset,writeset和exceptset中,如果我们对某一个的条件不感兴趣,就可以把它设为空指针
  • maxfdpl参数指定待测试的描述符个数,它的值是待测试的最大描述符加1(因此我们把该参数命名为maxfdp1)

  • select函数修改由指针readset,writeset和exceptset所指向的描述符集,因而这三个参数都是值-结果参数
  • 调用该函数时,我们指定所关心的描述符的值,该函数返回时,结果将指示哪些描述符已就绪

  • 使用select时最常见的两个编程错误是
    • 忘了对最大描述符加1
    • 忘了描述符集是值-结果参数。

1.5

  • 终止网络连接的通常方法是调用close函数。不过close有两个限制,却可以使用shutdown来避免
    • close把描述符的引用计数减一,仅在该计数变为0时才关闭套接字。使用shutdown可以不管引用计数就激发TCP的正常连接终止序列
    • close终止读和写两个方向的数据传送。既然TCP连接是全双工的,有时候我们需要告知对端我们已经完成了数据发送,即使对端仍有数据要发送给我们
  • 声明
    • #include <sys/socket.h>
    • int shutdown (int sockfd, int howto);
  • 该函数的行为依赖于howto参数的值
    • SHUT_RD,关闭连接的读这一半 – 套接字中不再有数据可接收,而且套接字接受缓冲区中的现有数据都被丢弃
    • SHUT_WR,关闭连接的写这一半 – 对于TCP套接字,这称为半关闭(half-close)
    • SHUT_RDWR,连接的读半部和写半部都关闭 – 这与调用shutdown两次等效:第一次调用指定SHUT_RD,第二次调用指定SHUT_WR

1.6

  • pselect函数是由POSIX发明的,如今有许多Unix变种支持它
  • pselect相对于通常的select有两个变化
    • pselect使用timespec结构,而不使用timeval结构。timespec结构是POSIX的又一个发明
    • pselect函数增加了第六个参数:一个指向信号掩码的指针。

1.7

  • poll提供的功能与select类似,不过在处理流设备时,它能够提供额外的信息
  • 声明
    • #include <poll.h>
    • int poll (struct pollfd *fdarray, unsigned long nfds,int timeout);
  • 第一个参数是指向一个结构数组第一个元素的指针。每个数组元素都是一个pollfd结构,用于指定测试某个给定描述符fd的条件
  • timeout参数指定poll函数返回前等待多长时间。它是一个指定应等待毫秒数的正值

套接字选项

1.1

  • 有很多方法来获取和设置影响套接字的选项
    • getsockopt和setsockopt函数
    • fcntl函数
    • ioctl函数
  • 套接字选项粗分为两大基本类型:
    • 一是,启用或禁止某个特性的二元选项(称为标志选项)
    • 二是,取得并返回我们可以设置或检查的特定值的选项(称为值选项)

1.2

  • 与代表”file control”(文件控制)的名字相符,fcntl函数可执行各种描述符控制操作
  • 声明:
    • #include <fcntl.h>
    • int fcntl (int fd, int cmd, ... /*int arg*/);
  • 每种描述符(包括套接字描述符)都有一组由F_GETFL命令获取或由F_SETFL命令设置的文件标志。
  • 其中影响套接字描述符的两个标志是:
    • O_NONBLOCK – 非阻塞式I/O
    • O_ASYNC – 信号驱动式I/O
  • 设置某个文件状态标志的唯一正确的方法是:先取得当前标志,与新标志逻辑或后再设置标志

名字与地址转换

1.1

  • 到目前为止,本书中所有例子都用数值地址来表示主机,用数值端口号来标识服务器,然而出于许多理由,我们应该使用名字而不是数值
  • 名字比较容易记住,数值地址可以变动而名字保持不变,随着往IPv6上转移,数值地址变得相当长,手工键入数值地址更易出错

  • gethostbyname和gethostbyaddr在主机名字与IPv4地址之间转换
  • getservbyname和getservbyport在服务名字和端口号之间进行转换
  • getaddrinfo和getnameinfo,分别用于主机名字和IP地址之间以及服务名字和端口号之间的转换

1.2

  • 域名系统(Domain Name System, DNS),主要用于主机名字和IP地址之间的映射
  • DNS中的条目称为资源记录(resource record, RR)

  • 每个组织机构往往运行一个或多个名字服务器(name server),它们通常就是所谓的BIND(Berkeley Internet Name Domain)程序
  • 诸如我们在本书中编写的客户和服务器等应用程序通过调用称为解析器(resolver)的函数库中的函数接触DNS服务器
  • 常见的解析器函数是gethostbyname和gethostbyaddr,前者把主机名映射成IPv4地址,后者则执行相反的映射

1.3

  • 查找主机名最基本的函数是gethostbyname。如果调用成功,它就返回一个指向hostent结构的指针,该结构中含有所查找主机的所有IPv4地址。这个函数的局限是只能返回IPv4地址,而getaddrinfo函数能够同时处理IPv4地址和IPv6地址
  • 声明:
    • #include <netdb.h>
    • struct hostent *gethostbyname (const char *hostname);
  • gethostbyaddr函数试图由一个二进制的IP地址找到相应的主机名,与gethostbyname的行为刚好相反
  • 声明:
    • #include <netdb.h>
    • struct hostent *gethostbyaddr(const char *addr, socklent len, int family);

1.3

  • tcp_listen执行TCP服务器的通常步骤:创建一个TCP套接字,给他捆绑服务器的众所周知端口,并允许接受外来的连接请求
  • 实现步骤:
    • 调用getaddrinfo
    • 创建套接字并给他捆绑地址

守护进程和inetd超级服务器

1.1

  • 守护进程(daemon),是在后台运行且不与任何控制终端关联的进程。Unix系统通常有很多守护进程在后台运行(约在20到50个的量级),执行不同的管理任务。

  • 守护进程没有控制终端通常源于它们由系统初始化脚本启动。然而守护进程也可能从某个终端由用户在shell提示符下键入命令启动,这样的守护进程必须亲自脱离与控制终端的关联,从而避免与作业控制,终端会话管理,终端产生信号等发生任何不期望的交互,也可以避免在后台运行的守护进程非预期地输出到终端。
  • 守护进程有多种启动方法:
    • 在系统启动阶段,许多守护进程由系统初始化脚本启动。这些脚本通常位于/etc/目录或以/etc/rc开头的某个目录中,它们的具体位置和内容却是实现相关的。由这些脚本启动的守护进程一开始时拥有超级用户特权。有若干个网络服务器通常从这些脚本启动:inetd超级服务器,Web服务器,邮件服务器
    • 许多网络服务器由inetd超级服务器启动。inetd自身由上一条中的某个脚本启动。inetd监听网络请求(Telnet, FTP)等,每当有一个请求到达时,启动相应的实际服务器(Telnet服务器,FTP服务器等);
    • cron守护进程按照规则定期执行一些程序,而由它启动执行的程序同样作为守护进程运行。cron自身由第一条启动方法中的某个脚本启动
    • at命令用于指定将来某个时刻的程序执行。这些程序的执行时刻到来时,通常由cron守护进程启动执行它们,因此这些程序同样作为守护进程运行
    • 守护进程还可以从用户终端或在前台后在后台启动。
  • 因为守护进程没有控制终端,所以当有事发生时它们得有输出消息的某种方法可用,而这些消息既可能是普通的通告性消息,也可能是需由系统管理员处理的紧急事件消息。
  • syslog函数,是输出这些消息的标准方法,它把这些消息发送给syslogd守护进程

1.2

  • 既然守护进程没有控制终端,它们就不能把消息fprintf到stderr上。从守护进程中登记消息的常用技巧就是调用syslog函数
  • 声明:
    • #include <syslog.h>
    • void syslog(int priority, const char *message, ...);
  • 本函数的priority参数是级别(level)和设施(fcility)两者的组合
    • 日志消息的level可从0到7,它们是按从高到低的顺序排列的。如果发送者未指定level值,那就默认为LOG_NOTICE
      • LOG_EMERG – 0 – 系统不可用(最高优先级)
      • LOG_ALERT – 1 – 必须立即采取行动
      • LOG_CRIT – 2 – 临界条件
      • LOG_ERR – 3 – 出错条件
      • LOG_WARNING– 4 – 警告条件
      • LOG_NOTICE – 5 – 正常然而重要的条件(默认值)
      • LOG_INFO – 6 – 通告消息
      • LOG_DEBUG – 7 – 调试级消息(最低优先级)
    • 日志消息还包含一个用于标识消息发送进程类型的facility。如果发送者未指定facility值,那就默认为LOG_USER
  • facility和level的目的在于:允许在/etc/syslog.conf文件中统一配置来自同一给定设施的所有消息,或者统一配置具有相同级别的所有消息

  • 当syslog被应用进程首次调用时,它创建一个Unix域数据报套接字,然后调用connect连接到由syslogd守护进程创建的Unix域数据报套接字的众所周知路径名(例如/var/run/log)。这个套接字一直保持打开,直到进程终止为止。
  • 作为替换,进程也可以调用openlog和closelog:openlog可以在首次调用syslog前调用,closelog可以在应用进程不再需要发送日志消息时调用
  • 声明:
    • #include <syslog.h>
    • void openlog(const char *ident, int options, int facility);
    • void closelog(void);
  • ident参数,是一个由syslog冠于每个日志消息之前的字符串。它的值通常是程序名
  • options参数,由一个或多个常值的逻辑或构成
    • LOG_CONS – 若无法发送到syslogd守护进程,则登记到控制台
    • LOG_NDELAY– 不延迟打开,立即创建套接字
    • LOG_PERROR– 既发送到syslogd守护进程,又登记到标准错误输出
    • LOG_PID – 随每个日志消息登记进程ID
  • openlod的facility参数,为没有指定设施的后续syslog调用指定一个默认值

1.3

  • 守护进程,是在后台运行并独立于所有终端控制的进程。许多网络服务器作为守护进程运行。守护进程产生的所有输出通常通过调用syslog函数发送给syslogd守护进程。系统管理员可根据发送消息的守护进程以及消息的严重级别,完全控制这些消息的处理方式
  • 启动任意一个程序并让它作为守护进程运行需要以下步骤:
    • 调用fork以转到后台运行
    • 调用setsid建立一个新的POSIX会话并成为会话头进程,
    • 再次fork以避免无意中获得新的控制终端,改变工作目录和文件创建模式掩码
    • 最后关闭所有非必要的描述符

1.4

  • recv和send函数,类似标准的read和write函数,不过需要一个额外的参数
  • 声明:
    • #include <sys/socket.h>
    • ssize_t recv (int sockfd, void *buff, size_t nbytes, int flags);
    • ssize_t send (int sockfd, const void *buff, size_t nbytes, int flags);
  • recv和send的前三个参数等同于read和write的三个参数,flags参数的值或为0.常见的逻辑或
    • MSG_DONTROUTE – 绕过路由表查找,本标志告知内核目的主机在某个直接连接的本地网络上,因而无需执行路由表查找 – send
    • MSG_DONTWAIT – 仅本操作非阻塞,本标志在无需打开相应套接字的非阻塞标志的前提下,把单个I/O操作临时指定为非阻塞,接着执行I/O操作,然后关闭非阻塞标志 – recv,send
    • MSG_OOB – 发送或接收外带数据 ,对于send,本标志指明即将发送带外数据;对于recv,本标志指明即将读入的是带外数据而不是普通数据 – recv,send
    • MSG_PEEK – 窥看外来消息,本标志适用于recv和recvfrom,它允许我们查看已可读取的数据,而且系统不在recv或recvfrom返回后丢弃这些数据 – recv
    • MSG_WAITALL – 等待所有数据,它告知内核不要再尚未读入请求数目的字节之前让一个读操作返回 – recv

1.5

  • readv和writev函数,类似read和write,不过readv和writev允许单个系统调用读入到或写出自一个或多个缓冲区。这些操作分别称为分散读(scatter read)和集中写(gather write),因为读操作的输入数据被分散到多个应用缓冲区中,而来自多个应用缓冲区的输出数据则被集中提供给单个写操作
  • 声明:
    • #include <sys/uio.h>
    • ssize_t readv (int filedes, const struct iovec *iov, int iovcnt);
    • ssize_t writev (int filedes, const struct iovec *iov, int iovcnt);
  • readv和writev这两个函数可用于任何描述符,而不仅限于套接字。
  • 另外,writev是一个原子操作,意味着对于一个基于记录的协议(例如UDP)而言,一次writev调用只产生单个UDP数据包

1.6

  • recvmsg和sendmsg,这两个函数是最通用的I/O函数。
  • 实际上,我们可以把所有read,readv,recv和recvfrom调用替换成recvmsg调用。类似的,各种输出函数调用也可以替换成sendmsg调用
  • 声明:
    • #include <sys/socket.h>
    • ssize_t recvmsg (int sockfd, struct msghdr *msg, int flags);
    • ssize_t sendmsg (int sockfd, struct msghdr *msg, int flags);
  • 这两个函数把大部分参数封装到一个msghdr结构中:
    1
    2
    3
    4
    5
    6
    7
    8
    
      struct msghdr {
          void *msg_name;           /* protocol address */
          socklen_t msg_namelen;    /* size of protocol address */
          struct iovec *msg_iov;    /* scatter/gather array */
          int msg_iovlen;           /* elements in msg_iov */
          void *msg_control;        /* ancillary data (cmsghdr struct) */
          socklen_t msg_controllen; /* flags returned by recvmsg() */
      };
    
    • msg_name和msg_namelen,这两个成员用于套接字未连接的场合(例如未连接UDP套接字)。它们类似recvfrom和sendto的第五个和第六个参数:msg_name指向一个套接字地址结构,调用者再其中存放接收者(对于sendmsg调用)或发送者(对于recvmsg调用)的协议地址。如果无需指明协议地址,msg_name应置为空指针。msg_namelen对于sendmsg是一个值参数,对于recvmsg却是一个值-结果参数
    • msg_iov和msg_iovlen,这两个成员指定输入或输出缓冲区数组(即iovec结构数组),类似readv或writev的第二个和第三个参数
    • msg_control和msg_controllen,这两个成员指定可选的辅助数据的位置和大小。msg_controllen对于recvmsg是一个值-结果参数

1.7

  • 辅助数据(ancillary data),可通过调用sendmsg和recvmsg这两个函数,使用msghdr结构中的msg_control和msg_controllen这两个成员发送和接收。
  • 辅助数据的另一个称谓,是控制信息(control information)

  • 辅助数据由一个或多个辅助数据对象(ancillary data object)构成,每个对象以一个定义在头文件<sys/socket.h>中的cmsghdr结构开头
  • 声明:
    1
    2
    3
    4
    5
    
      struct cmsghdr {
          socklen_t cmsg_len;    /* length in bytes, including this structure */
          int cmsg_level;        /* originating protocol */
          int cmsg_type;         /* protocol-specific type */
      };
    
  • msg_control指向第一个辅助数据对象,辅助数据的总长度则由msg_controllen指定。每个对象开头都是一个描述该对象的cmsghdr结构。

1.8

  • 到目前为止的所有例子中,我们一直使用也称为Unix I/O – 包括read,write这两个函数及它们的变体(recv,send等等)的函数执行I/O。这些函数围绕描述符(descriptor)工作,通常作为Unix内核中的系统调用实现
  • 执行I/O的另一个方法是使用标准I/O函数库(standard I/O library)。这个函数库由ANSI C标准规范,意在便于移植到支持ANSI C的非Unix系统上

  • 标准I/O函数库执行以下三类缓冲:
    • 完全缓冲(fully buffering),意味着只在出现下列情况时才发生I/O:缓冲区满,进程显示调用fflush,或进程调用exit终止自身。标准I/O缓冲区的通常大小为8192字节
    • 行缓冲(line buffering),意味着只在出现下列情况时才发生I/O:碰到一个换行符,进程调用fflush,或进程调用exit终止自身
    • 不缓冲(unbuffering),意味着每次调用标准I/O输出函数都发生I/O
  • 标准I/O函数库的大多数Unix实现使用如下规则:
    • 标准错误输出总是不缓冲
    • 标准输入和标准输出完全缓冲,除非它们指代终端设备(这种情况下它们行缓冲)
    • 所有其他I/O流都是完全缓冲,除非它们指代终端设备(这种情况下它们行缓冲)
  • 套接字不是终端设备,输出流是完全缓冲的。存在的问题有两个解决办法:
    • 第一个办法是通过调用setvbuf迫使这个输出流变为行缓冲
    • 第二个办法是在每次调用fputs之后通过调用fflush强制输出每个回射行
  • 然而在现实使用中,这两种办法都易于犯错。大多数情况下,最好的解决办法是:彻底避免在套接字上使用标准I/O函数库

1.9

  • 在套接字操作上设置时间限制的方法有三个:
    • 使用alarm函数和SIGALRM信号;
    • 使用由select提供的时间限制
    • 使用较新的SO_RCVTIMEO和SO_SNDTIMEO套接字选项
  • 第一个方法易于使用,不过涉及信号处理,而信号处理可能导致竞争条件。使用select意味着我们阻塞在指定过时间限制的这个函数上,而不是阻塞在read,write或connect调用上。第三个方法也易于使用,不过并非所有实现都提供

  • recvmsg和sendmsg是所提供的五组I/O函数中最为通用的。它们组合了如下能力:
    • 指定MSG_XXX标志(出自recv和send)
    • 返回或指定对端的协议地址(出自recvfrom和sendto)
    • 使用多个缓冲区(出自readv和writev)
    • 此外,还增加了两个新的特性:给应用进程返回标志,接收或发送辅助数据
  • 辅助数据,由一个或多个辅助数据对象构成,每个对象都以一个cmsghdr结构打头,它指定数据的长度,协议级别及类型。五个以CMSG_打头的函数可用于构建和分析辅助数据

  • C标准I/O函数库也可以用在套接字上,不过这么做将在已经由TCP提供的缓冲级别之上新增一级缓冲。实际上,对由标准I/O函数库执行的缓冲缺乏了解是使用这个函数库最常见的问题。既然套接字不是终端设备,这个潜在问题的常用解决办法就是把标准I/O流设置成不缓冲,或者干脆不要在套接字上使用标准I/O

Unix域协议

1.1

  • Unix域协议并不是一个实际的协议族,而是在单个主机上执行客户/服务器通信的一种方法,所用API就是在不同主机上执行客户/服务器通信所用的API(套接字APT)
  • Unix域提供两类套接字:字节流套接字(类似TCP)和数据包套接字(类似UDP)

1.2

  • socketpair函数,创建两个随后连接起来的套接字。本函数仅适用于Unix域套接字
  • 声明:
    • #include <sys/socket.h>
    • int socketpair (int family, int type, int protocol, int sockfd[2]);
  • family参数,必须为AF_LOCAL,
  • protocol参数,必须为0
  • type参数,既可以是SOCK_STREAM,也可以是SOCK_DGRAM
  • 新创建的两个套接字描述符作为sockfd[0]和sockfd[1]返回
  • 本函数类似Unix的pipe函数,会返回两个彼此连接的描述符。这样创建的两个套接字不曾命名,也就是说其中没有涉及隐式的bind调用

  • 同一个主机上客户和服务器之间的描述符传递是一个非常有用的技术,它通过Unix域套接字发生

非阻塞式I/O

1.1

  • 套接字的默认状态是阻塞的。这就意味着当发出一个不能立即完成的套接字调用时,其进程将被投入睡眠,等待响应操作完成。可能阻塞的套接字调用可分为以下四类:
    • 输入操作,包括read,readv,recv,recvfrom和recvmsg共五个函数。如果某个进程对一个阻塞的TCP套接字(默认设置)调用这些输入函数之一,而且该套接字的接收缓冲区中没有数据可读,该进程将被投入睡眠,直到有一些数据到达。对于非阻塞的套接字,如果输入操作不能被满足(对于TCP套接字即至少有一个字节的数据可读,对于UDP套接字即有一个完整的数据报可读),相应调用将立即返回一个EWOULDBLOCK错误
    • 输出操作,包括write,writev,send,sendto和sendmsg共五个函数。对于一个TCP套接字,内核将从应用进程的缓冲区到该套接字的发送缓冲区复制数据。对于阻塞的套接字,如果其发送缓冲区中没有空间,进程将被投入睡眠,直到有空间为止。对于一个非阻塞的TCP套接字,如果其发送缓冲区中根本没有空间,输出函数调用将立即返回一个EWOULDBLOCK错误。如果其发送缓冲区中有一些空间,返回值将是内核能够复制到该缓冲区中的字节数。这个字节数也称为不足计数(short count)
    • 接受外来连接,即accept函数。如果对一个阻塞的套接字调用accept函数,并且尚无新的连接到达,调用进程将被投入睡眠。如果对一个非阻塞的套接字调用accept函数,并且尚无新的连接到达,accept调用将立即返回一个EWOULDBLOCK错误
    • 发起外出连接,即用于TCP的connect函数。(回顾一下,我们直到connect同样可用于UDP,不过它不能使一个“真正”的连接建立起来,它只是使内核保存对端的IP地址和端口号)。TCP连接的建立涉及一个三路握手过程,而且connect函数一直要等到客户收到对于自己的SYN的ACK为止才返回。这意味着TCP的每个connect总会阻塞其调用进程至少一个到服务器的RTT时间。如果对一个非阻塞的TCP套接字调用connect,并且连接不能立即建立,那么连接的建立能照样发起(例如送出TCP三路握手的第一个分组),不过会返回一个EINPROGRESS错误

ioctl操作

1.1

  • ioctl函数,传统上一直作为那些不适合归入其他精细定义类别的特性的系统接口。POSIX致力于摆脱处于标准化过程中的特定功能的ioctl接口,办法是为它们创造一些特殊的函数以取代ioctl请求

  • 网络程序(特别是服务器程序)经常在程序启动执行后使用ioctl获取所在主机全部网络接口的信息,包括:接口地址,是否支持广播,是否支持多播,等等。我们将自行开发用于返回这些信息的函数

1.2

  • ioctl函数,影响由fd参数引用的一个打开的文件
  • 声明:
    • #include <unistd.h>
    • int ioctl (int fd, int request, ... /* void *arg */);
  • 其中第三个参数总是一个指针,但指针的类型依赖于request参数
  • 我们可以把和网络相关的请求(request)划分为六类:
    • 套接字操作(是否位于带外标记等)
    • 文件操作(设置或清楚非阻塞标志等
    • 接口操作(返回接口列表,获取广播地址等)
    • ARP高速缓存操作(创建,修改,获取或删除)
    • 路由表操作(增加或删除)
    • 流系统

1.3

  • 需要处理网络接口的许多程序沿用的初始步骤之一,就是从内核获取配置在系统中的所有接口
  • 本任务由SIOCGIFCONF请求完成,它使用ifconfi接口,ifconfi又使用ifreq结构

路由套接字

1.1

  • 我们对路由套接字的主要兴趣点在于,使用sysctl函数检查路由表和接口列表。创建路由套接字(一个AF_ROUTE域的原始套接字)需要超级用户权限,然而使用sysctl检查路由表和接口列表的进程却不限用户权限
  • 声明:
    • #include <sys/param.h>
    • #include <sys/sysctl.h>
    • int sysctl (int *name, u_int namelen, void *oldp, size_t oldlenp, void *newp, size_t newlen);
  • name参数,是指定名字的一个整数数组,
  • namelen参数,指定该数组中的元素数目。
  • 为了获取某个值,oldp参数,指向一个供内核存放该值的缓冲区
  • oldlenp则是一个值-结果参数:函数被调用时,oldlenp指向的值指定该缓冲区的大小;函数返回时,该值给出内核存放在该缓冲区中的数据量
  • 为了设置某个新值,newp参数,指向一个大小为newlen参数值的缓冲区。如果不准备指定一个新值,那么newp应为一个空指针,newlen应为0

广播

1.1

  • 广播(broadcasting)和多播(multicasting)。本书迄今为止所有的例子处理的都是单播(unicasting):一个进程就与另一个进程通信。实际上TCP只支持单播寻址,而UDP和原始IP还支持其他寻址类型
  • IPv6往寻址体系结构中增加了任播(anycasting)方式。任播允许从一组通常提供相同服务的主机中选择一个(一般是选择按某种测度而言离源主机最近的)。

  • 多播支持在IPv4中是可选的,在IPv6中却是必需的
  • IPv6不支持广播。使用广播的任何IPv4应用程序一旦移植到IPv6就必须改用多播重新编写
  • 广播和多播要求用于UDP或原始IP,它们不能用于TCP

  • 广播的用途之一,是在本地子网定位一个服务器主机,前提是已知或认定这个服务器主机位于本地子网,但是不知道它的单播IP地址。这种操作也称为资源发现(resource discovery)。另一个用途是在有多个客户主机和单个服务器主机通信的局域网环境中尽量减少分组流通

  • 广播发送的数据报由发送主机某个所在子网上的所有主机接收。广播的劣势在于同一子网上的所有主机都必须处理数据报

多播

1.1

  • 单播地址标识单个IP接口,广播地址标识某个子网的所有IP接口,多播地址标识一组IP接口。
  • 单播和广播是寻址方案的两个极端(要么单个要么全部),多播则意在两者之间提供一种折衷方案
  • 多播数据报只应该由它感兴趣的接口接收,也就是说由运行相应多播会话应用系统的主机上的接口接收

  • 套接字API为支持多播而增添的内容比较简单:九个套接字选项
    • 其中三个影响目的地址为多播地址的UDP数据报的发送
    • 另外六个影响主机对于多播数据报的接收

信号驱动式I/O

1.1

  • 信号驱动式I/O,是指进程预先告知内核,使得当某个描述符上发生某事时,内核使用信号通知相关进程。
  • POSIX通过aio_XXX函数提供真正的异步I/O。这些函数允许进程指定I/O操作完成时是否由内核产生信号以及产生什么信号。

  • 针对一个套接字使用信号驱动式I/O(SIGIO)要求进程执行以下三个步骤:
    • 建立SIGIO信号的信号处理函数
    • 设置该套接字的属主,通常使用fcntl的F_SETOWN命令设置
    • 开启该套接字的信号驱动式I/O,通常通过使用fcntl的F_SETFL命令打开O_ASYNC标志完成

1.2

  • 在UDP上使用信号驱动式I/O是简单的。SIGIO信号在发生以下事件时产生:
    • 数据报到达套接字;
    • 套接字上发生异步错误
  • 因此,当捕获对于某个UDP套接字的SIGIO信号时,我们调用recvfrom或者读入到达的数据报,或者获取发生的异步错误。

  • 信号驱动式I/O对于TCP套接字近乎无用。问题在于该信号产生的过于频繁,并且它的出现并没有告诉我们发生了什么事件

线程

1.1

  • 在传统的UNIX模型中,当一个进程需要另一个实体来完成某事时,它就fork一个子进程并让子进程去执行处理。
  • Unix上的大多数网络服务器程序就是这么编写的:父进程accept一个连接,fork一个子进程,该子进程处理与该连接对端的客户之间的通信。

  • 尽管这种范式多少年来一直用的挺好,fork调用却存在一些问题:
    • fork是昂贵的。fork要把父进程的内存映像复制到子进程,并在子进程中复制所有描述符,如此等等。当今的实现使用称为写时复制(copy-on-write)的技术,用以避免在子进程切实需要自己的副本之前把父进程的数据空间复制到子进程。然而即便有这样的优化措施,fork仍然是昂贵的
    • fork返回之后父子进程之间信息的传递需要进程间通信(IPC)机制。调用fork之前父进程向尚未存在的子进程传递信息相当容易,因此子进程将从父进程数据空间及所有描述符的一个副本开始运行。然而从子进程往父进程返回信息却比较费力。
  • 线程,有助于解决这两个问题。线程有时称为轻权进程(lightweight process),因为线程比进程“权重轻些”。也就是说,线程的创建可能比进程的创建快10-100倍
  • 同一进程内的所有线程共享相同的全局内存。这使得线程之间易于共享信息,然而伴随这种简易性而来的确实同步(synchronization)问题

  • 同一进程内的所有线程除了共享全局变量外,还共享:
    • 进程指令;
    • 大多数数据
    • 打开的文件(即描述符)
    • 信号处理函数和信号处置
    • 当前工作目录
    • 用户ID和组ID
  • 不过每个线程有各自的:
    • 线程ID;
    • 寄存器集合,包括程序计数器和栈指针
    • 栈(用于存放局部变量和返回地址);
    • errno;
    • 信号掩码;
    • 优先级

1.2

  • 当一个程序由exec启动执行时,称为初始线程(initial thread)或主线程(main thread)的单个线程就创建了。其余线程则由pthread_create函数创建
  • 声明:
    • #include <pthread.h>
    • int pthread_create (pthread_t *tid, const pthread_attr_t *attr, void *(*func)(void *), void *arg);
  • 一个进程内的每个线程都由一个线程ID(thread ID)标识,其数据类型为pthread_t(往往是unsigned int)。如果新的线程成功创建,其ID就通过tid指针返回
  • 每个线程都有许多属性(attribute):优先级,初始栈大小,是否应该成为一个守护线程,等等。
  • 创建一个线程时,我们最后指定的参数是由该线程执行的函数及其参数。该线程通过调用这个函数开始执行,然后或显示终止(通过调用pthread_exit),或者隐式地终止(通过让函数返回)。该函数的地址由func参数指定,该函数的唯一调用参数是指针arg。如果我们需要给该函数传递多个参数,我们就得把它们打包成一个结构,然后把这个结构的地址作为单个参数传递给这个起始函数。
  • 注意func和arg的声明。func所指函数作为参数接受一个通用指针(void *),又作为返回值返回一个通用指针(void *)。这使得我们可以把一个指针(它指向我们期望的任何内容)传递给线程,又允许线程返回一个指针(它同样指向我们期望的任何内容)
  • 通常情况下Pthread函数的返回值成功时为0,出错时为某个非0值

1.3

  • 我们可以通过调用pthread_join等待一个给定线程终止。对比线程和UNIX进程,pthread_create类似于fork,pthread_join类似于waitpid
  • 声明:
    • #include <pthread.h>
    • int pthread_join (pthread_t *tid, void **status);
  • 我们必须指定要等待线程的tid。不幸的是,Pthread没有办法等待任意一个线程(类似指定进程ID参数为-1调用waitpid)
  • 如果status指针非空,来自所等待线程的返回值(一个指向某个对象的指针)将存入由status指向的位置

1.4

  • 每个线程都有一个在所属进程内标识自身的ID。线程ID由pthread_create返回,而且我们已经看到pthread_join使用它。每个线程使用pthread_self获取自身的线程ID
  • 声明:
    • #include <pthread.h>
    • pthread_t pthread_self (void);
  • 对比线程和UNIX进程,pthread_self类似于getpid

1.5

  • 一个线程或者是可汇合的(joinable,默认值),或者是脱离的(detached)。当一个可汇合的线程终止时,它的线程ID和退出状态将留存到另一个线程对他调用pthread_join。脱离的线程却像守护进程,当它们终止时,所有相关资源都被释放,我们不能等待它们终止。如果一个线程需要知道另一个线程什么时候终止,那就最好保持第二个线程的可汇合状态。
  • pthread_detach函数,把指定的线程转变为脱离状态
  • 声明:
    • #include <pthread.h>
    • int pthread_detach (pthread_t tid);
  • 本函数通常由想让自己脱离的线程调用,就如以下语句:
    • pthread_detach (pthread_self());

1.6

  • 让一个线程终止的方法之一,是调用pthread_exit。
  • 声明:
    • #include <pthread.h>
    • void pthread_exit (void *status);
  • 指针status不能指向局部于调用线程的对象,因为线程终止时这样的对象也消失

  • 让一个线程终止的另外两个方法是:
    • 启动线程的函数(即pthread_create的第三个参数)可以返回。既然该函数必须声明成返回一个void指针,它的返回值就是相应线程的终止状态
    • 如果进程的main函数返回或者任何线程调用了exit,整个进程就终止,其中包括它的任何线程。

1.7

  • 我们称线程编程为并发编程(concurrent programming)或并行编程(parallel programming),因为多个线程可以并发地(或并行地)运行且访问相同的变量

  • 我们刚才讨论的多个线程更改一个共享变量的问题是最简单的问题。其解决办法是,使用一个互斥锁(mutex,代表mutual exclusion)保护这个共享变量;访问该变量的前提条件是持有该互斥锁。
  • 按照Pthread,互斥锁是类型为pthread_mutex_t的变量。我们使用以下两个函数为一个互斥锁上锁和解锁:
  • 声明:
    • #include <pthread.h>
    • int pthread_mutex_lock (pthread_mutex_t *mptr);
    • int pthread_mutex_unlock (pthread_mutex_t *mptr);
  • 如果试图上锁已被另外某个线程锁住的一个互斥锁,本线程将被阻塞,直到该互斥锁倍解锁为止

1.8

  • 互斥锁适合于防止同时访问某个共享变量,但是我们需要另外某种在等待某个条件发生期间能让我们进入睡眠的东西
  • 轮询(polling),就是不断地循环,每次循环检查一下条件,相当浪费CPU事件

  • 我们需要一个让主循环进入睡眠,直到某个线程通知它有事可做才醒来的方法。条件变量(condition variable)结合互斥锁能够提供这个功能。互斥锁提供互斥机制,条件变量提供信号机制
  • 按照Pthread,条件变量是类型为pthread_cond_t的变量。以下两个函数使用条件变量:
  • 声明:
    • #include <pthread.h>
    • int pthread_cond_wait (pthread_cond_t *cptr, pthread_mutex_t *mptr);
    • int pthread_cond_signal (pthread_cond_t *cptr);
  • 第二个函数的名字中”signal“一词并不指称Unix的SIGXXX信号

  • 为什么每个条件变量都要关联一个互斥锁呢?因为”条件“通常是线程之间共享的某个变量的值。允许不同线程设置和测试该变量要求有一个与该变量关联的互斥锁
  • 要求pthread_cond_wait被调用时其所关联的互斥锁必须是上锁的,该函数作为单个原子操作解锁该互斥锁并把调用线程投入睡眠也是出于这个理由

  • pthread_cond_signal通常唤醒等在相应条件变量上的单个线程。有时候一个线程直到自己应该唤醒多个线程,这种情况下它可以调用pthread_cond_broadcast唤醒等在相应条件变量上的所有线程
  • 声明:
    • #include <pthread.h>
    • int pthread_cond_broadcast (pthread_cond_t *cptr);
    • int pthread_cond_timedwait (pthread_cond_t *cptr, pthread_mutex_t *mptr, const struct timespec *abstime);
  • pthread_cond_timedwait允许线程设置一个阻塞时间的限制。

1.9

  • 创建一个新线程通常比使用fork派生一个新进程快得多。仅仅这一点就能够体现线程在繁重使用的网络服务器上的优势,然而线程编程是一个新的编程范式,需要有所训练。
  • 同一进程内的所有线程共享全局变量和描述符,从而允许不同线程之间共享这些信息。然而这种共享却引入了同步问题,我们必须使用的Pthread同步原语是互斥锁和条件变量。共享数据的同步几乎是每个线程化应用程序必不可少的部分。
  • 我们将在第三十章中重新回到线程模型,讨论另外一个服务器程序设计范式:服务器在启动时创建一个线程池,下一个客户请求就由该池中某个闲置的线程来处理

IP选项

1.1

  • 源路径(source route),是由IP数据报的发送者指定的一个IP地址列表。如果源路径是严格的(strict),那么数据报必须且只能逐一经过所列的节点,也就是说列在源路径中的所有节点必须前后互为邻居。如果源路径是宽松的(loose),那么数据报必须逐一经过所列的节点,不过也可以经过未列在源路径中的其他节点

  • 在10个已定义的IPv4选项中最常用的是源路径选项,不过出于安全考虑,它的使用正在日益萎缩。IPv4首部中选项的访问通过IP_OPTIONS套接字选项完成

原始套接字

1.1

  • 原始套接字提供普通的TCP和UDP套接字所不提供的以下三个能力:
    • 有了原始套接字,进程可以读与写ICMPv4, IGMPv4和ICMPv6等分组
    • 有了原始套接字,进程可以读写内核不处理其协议字段的IPv4数据报
    • 有了原始套接字,进程还可以使用IP_HDRINCL套接字选项自行构造IPv4首部,通常用于诊断目的
  • ping和traceroute这两个常用的诊断工具,使用原始套接字完成任务

数据链路访问

1.1

  • 目前大多数操作系统都为应用提供访问数据链路层的强大功能。这种功能可以提供如下能力:
    • 能够监视由数据链路层接收的分组,使得诸如tcpdump之类的程序能够在普通计算机系统上运行,而无需使用专门的硬件设备来监视分组。
    • 能够作为普通应用进程而不是内核的一部分运行某些程序

1.2

  • Linux先后有两个从数据链路层接收分组的方法。
    • 较旧的方法,是创建类型为SOCK_PACKET的套接字,这个方法的可用面较宽,不过缺乏灵活性
    • 较新的方法,是创建协议族为PF_PACKET的套接字,这个方法引入了更多的过滤和性能特性
  • 从数据链路层接收所有帧应如下创建套接字:
    • fd = socket (PF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); /* 较新方法 */
    • fd = socket (AF_INET, SOCK_PACKET, htons(ETH_P_ALL)); /* 较旧方法 */
  • 如果只想捕获IPv4帧,那就如下创建套接字
    • fd = socket (PF_PACKET, SOCK_RAW, htons(ETH_P_IP)); /* 较新方法 */
    • fd = socket (AF_INET, SOCK_PACKET, htons(ETH_P_IP)); /* 较旧方法 */
  • 用作socket调用的第三个参数的常值还有ETH_P_ARP, ETH_P_IPV6等

1.3

  • libpcap,是访问操作系统所提供的分组捕获机制的分组捕获函数库,它是与实现无关的。
  • 目前它只支持分组的读入

1.4

  • libnet函数库,提供构造任意协议的分组并将其输出到网络中的接口。它以与实现无关的方式提供原始套接字访问方式和数据链路访问的方式
  • libnet隐藏了构造IP,UDP和TCP首部的许多细节,并提供简单且便于移植的数据链路和原始套接字写出访问接口

1.5

  • 原始套接字,使得我们有能力读写内核不理解的IP数据报;数据链路层访问,则把这个能力进一步扩展成与读写任何类型的数据链路帧,而不仅仅是IP数据报。tcpdump也许是直接访问数据链路层的最常用程序
  • 不同操作系统有不同的数据链路层访问方法。但是,如果使用公开可得的分组捕获函数库libpcap,我们就可以忽略所有这些区别,依然编写出可移植的代码
  • 在不同系统上编写原始数据报可能各不相同。公开可得的libnet函数库隐藏了这些差异,所提供的数据接口即可以通过原始套接字访问,也可以在数据链路层上直接访问

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

1.1

  • 当开发一个Unix服务器程序时,我们有如下类型的进程控制可供选择
    • 本书第一个服务器程序即图1-9是一个迭代服务器(iterative server)程序,不过这种类型的使用情形极为有限,因为这样的服务器在完成对当前客户的服务之前无法处理已经等待服务的新客户
    • 图5-2是本书第一个并发服务器(concurrent server)程序,它为每个客户调用fork派生一个子进程。传统上大多数Unix服务器程序属于这种类型
    • 在6.8节,我们开发的另一个版本的TCP服务器程序由使用select处理任意多个客户的单个进程构成
    • 在图26-3中,我们的并发服务器程序被改为服务器为每个客户创建一个线程,以取代派生一个进程
  • 我们将在本章探究并发服务器程序设计的另两类变体:
    • 预先派生子进程(preforking),是让服务器在启动阶段调用fork创建一个子进程池。每个客户请求由当前可用子进程池中的某个(闲置)子进程处理
    • 预先创建线程(prethreading),是让服务器在启动阶段创建一个线程池,每个客户由当前可用线程池中的某个(闲置)线程处理
  • 我们将在本章审视预先派生子进程和预先创建线程这两种类型的众多细节:
    • 如果池中进程和线程不够多怎么办?
    • 如果池中进程和线程过多怎么办?
    • 父进程和子进程之间以及多个线程之间怎样彼此同步?
  • 我们已经探究了客户程序的各种设计范式,这里有必要汇总它们各自的优缺点:
    • 图5-5是基本的TCP客户程序。该程序存在两个问题。首先,进程在被阻塞以等待用户输入期间,看不到诸如对端关闭连接等网络事件。其次,它以停-等模式运作,批处理效率极低
    • 图6-9是下一个迭代客户程序,它通过调用select使得进程能够在等待用户输入期间得到网络事件通知。然而该程序存在不能正确地批量输入的问题。图6-13通过使用shutdown函数解决了这个问题
    • 从图16-3开始给出的是使用非阻塞式I/O实现的客户程序
    • 第一个超越单进程单线程设计范畴的客户程序是图16-10,它使用fork派生一个子进程,并由父进程(或子进程)处理从客户到服务器的数据,由子进程(或父进程)处理从服务器到客户的数据
    • 图26-2使用两个线程取代两个进程
  • 非阻塞式I/O版本尽管是最快的,其代码却比较复杂;使用两个进程或两个线程的版本相比之下代码简化得多,而运行速度只是稍逊而已

1.2

  • 传统上并发服务器调用fork派生一个子进程来处理每个客户。这使得服务器能够同时为多个客户服务,每个进程一个客户。
  • 客户数目的唯一限制是操作系统对以其名义运行服务器的用户ID能够同时拥有多少子进程的限制

1.3

  • 我们的第一个“增强”型服务器程序使用称为预先派生子进程(preforking)的技术。
  • 使用该技术的服务器不像传统意义的并发服务器那样为每个客户现场派生一个子进程,而死在启动阶段预先派生一定数量的子进程,当各个客户连接到达时,这些子进程立即就能为它们服务
  • 这种技术的优点在于无须引入父进程执行fork的开销就能处理新到的客户。缺点则是父进程必须在服务器启动阶段猜测需要预先派生多少子进程

  • 通过增加一些代码,服务器总能应对客户负载的变动。父进程必须做的就是持续监视可用(即闲置)子进程数,一旦该值降到低于某个阈值就派生额外的子进程。同样,一旦该值超过另一个阈值就终止一些过剩的子进程

1.4

  • BSD实现允许多个进程在引用同一个监听套接字的描述符上调用accept,然而这种做法也仅仅适用于在内核中实现accept的源自Berkeley的内核。相反,作为一个库函数实现accept的System V内核不允许这么做。
  • 解决办法是,让应用进程在调用accept前后安置某种形式的锁(lock),这样任意时刻只有一个子进程阻塞在accept调用中,其他子进程则则色在试图获取用于保护accept的锁上
  • 正如本系列丛书第二卷所述,我们有多种方法可用于提供包绕accept调用的上锁功能。本节我们使用以fcntl函数呈现的POSIX文件上锁功能

1.5

  • 对预先派生子进程服务器程序的最后一个修改版本,是只让父进程调用accept,然后把所接受的已连接套接字“传递”给某个子进程。这么做绕过了为所有子进程的accept调用提供上锁保护的可能需求,不过需要从父进程到子进程的某种形式的描述符传递。这种技术会使代码多少有点复杂,因为父进程必须跟踪子进程的忙闲状态,以便给空闲子进程传递新的套接字

  • 我们首先查看图30-21给出的child_make函数。在调用fork之前先创建一个字节流管道,它是一对Unix域字节流套接字(第十五章)。派生出子进程之后,父进程关闭其中一个描述符(sockfd[1]),子进程关闭另一个描述符(sockfd[0])。子进程还把流管道的自身拥有端(sockfd[1])复制到标准错误输出,这样每个子进程就通过读写标准错误输出和父进程通信。

1.6

  • 在支持线程的系统上,我们有理由预期在服务器启动阶段预先创建一个线程池以取代为每个客户现场创建一个线程的做法有类似的性能加速。
  • 本服务器的基本设计是,预先创建一个线程池,并让每个线程各自调用accept。取代让每个线程都阻塞在accept调用之中的做法,我们改用互斥锁以保证任何时刻只有一个线程在调用accept。这里没有理由使用文件上锁保护各个线程中的accept调用,因为对于单个进程中的多个线程,我们总可以使用互斥锁达到同样的目的

1.7

  • 最后一个使用线程的服务器程序设计范式,是在程序启动阶段创建一个线程池之后只让主线程调用accept并把每个客户连接传递给池中某个可用线程
  • 本设计范式的问题,在于主线程如何把一个已连接套接字传递给线程池中某个可用线程。这里有多个实现手段。
    • 我们原本可以如前使用描述符传递,不过既然所有线程和所有描述符都在同一个进程之内,我们没有必要把一个描述符从一个线程传递到另一个线程
    • 接收线程只需要知道这个已连接套接字描述符的值,二描述符传递实际传递的并非这个值,而是对这个套接字的一个引用,因而将返回一个不同于原值的描述符(该套接字的引用计数也被递增)

1.8

  • 我们在本章中讨论了九个不同的服务器程序设计范式,并针对同一个Web风格的客户程序分别运行了它们,以比较它们花在执行进程控制上的CPU时间
    • 迭代服务器(无进程控制,用作测量基准)
    • 并发服务器,每个客户请求fork一个子进程
    • 预先派生子进程,每个子进程无保护地调用accept
    • 预先派生子进程,使用文件上锁保护accept
    • 预先派生子进程,使用线程互斥锁上锁保护accept
    • 预先派生子进程,父进程向子进程传递套接字描述符
    • 并发服务器,每个客户请求创建一个线程
    • 预先创建线程服务器,使用互斥锁上锁保护accept
    • 预先创建线程服务器,由主线程调用accept
  • 经过比较,我们可以得出以下几点总结性意见:
    • 当系统负载较轻时,每来一个客户请求现场派生一个子进程为之服务的传统并发服务器程序模型就足够了。
    • 相比传统的每个客户fork一次设计范式,预先创建一个子进程池或一个线程池的设计范式能够把进程控制CPU时间降低10或以上。编写这些范式的程序并不复杂,不过需超越本章所给的例子的是:监视闲置子进程个数,随着所服务客户数的动态变化而增加或减少这个数目
    • 某些实现允许多个子进程或线程阻塞在同一个accept调用中,另一些实现却要求包绕accept调用安置某种类型的锁加以保护。文件上锁或Pthread互斥锁上锁都可以使用
    • 让所有子进程或线程自行调用accept通常比让父进程或主线程独自调用accept并把描述符传递给子进程或线程来的简单而快速
    • 由于潜在select冲突的原因,让所有子进程或线程阻塞在同一个accept调用中比让它们阻塞在同一个select调用中更可取
    • 使用线程通常远快于使用进程

1.1

  • 我们将在本章给出流系统的概貌以及应用程序用于访问某个流的函数

附录A

1.1

  • IP层提供无连接不可靠的数据报递送服务。它会尽最大努力把IP数据报递送到指定的目的地,然而并不保证它们一定到达,也不保证它们的到达顺序与发送顺序一致,还不保证每个IP数据报只到达一次。
  • 任何期望的可靠性(即无差错按顺序不重复地递送用户顺序)必须由上层提供。
    • 对于TCP(或SCTP)应用程序而言,这由TCP(或SCTP)本身完成
    • 对于UDP应用程序而言,这得由应用程序完成,因为UDP是不可靠的
  • IP层最重要的功能之一是路由(routing)。每个IP数据报包含一个源地址和一个目的地址

1.2

  • 32位长度的IPv4地址通常书写成以点号分隔的4个十进制数,称为点分十进制数记法(dotted-decimal notation),其中每个十进制数代表32位地址4个字节中的某一个
  • 无论在何时谈到IPv4网络或子网地址,所说的都是一个32位网络地址和一个相应的32位掩码。掩码中值为1的位涵盖网络地址部分,值为0的位涵盖主机地址部分。既然掩码中值为1的位置总是从最左位向右连续排列,值为0的位总是从最右位向左连续排列,因此地址掩码也可以使用表示从最左位向右排列的值为1的连续位数的前缀长度(prefix length)指定。
  • 举例来说,掩码是255.255.255.0,则前缀长度为24。这些IPv4地址被认为无类的,之所以这么称呼,是因为现在掩码是显式指定而非由地址类型暗指的。
  • IPv4网络地址通常书写成一个点分十进制数串,后跟一个斜杠,再跟以前缀长度。192.169.4.16/24
  • 使用无类地址要求无类路由,它通常称为无类域间路由(classless interdomain routing, CIDR)。使用CIDR的目的,在于减少因特网主干路由表的大小,延缓IPv4地址耗尽的速率。

1.3

  • IPv4地址通常划分子网。这么做增加了另外一级地址层次:
    • 网络ID(分配给网点)
    • 子网ID(由网点选择)
    • 主机ID(由网点选择)
  • 网络ID和子网ID之间的界线,由所分配网络地址的前缀长度确定,而这个前缀长度通常由相应的组织机构的ISP赋予。然而子网ID和主机ID之间的界线却由网点选择。
  • 某个给定子网上所有主机都共享同一个子网掩码(subnet mask),它指定子网ID和主机ID之间的界线。子网掩码中值为1的位涵盖网络ID和子网ID,值为0的位则涵盖主机ID

1.4

  • 环回地址,按照约定,地址127.0.0.1赋予环回接口。任何发送到这个IP地址的分组在内部被环送回来作为IP模块的输入,因而这些分组根本不会出现在网络上
  • 我们在同一个主机上测试客户和服务器程序时经常使用改地址。该地址通常为人所知的名字是INADDR_LOOPBACK

附录C 调试技术

1.1

  • tcpdump程序,该程序一边从网络读入分组一边显示关于这些分组的大量信息。它还能够只显示所指定的准则匹配的那些分组
  • 该程序可从http://www.tcpdump.org/获取,能够工作在许多不同版本的Unix上

1.2

  • netstat程序,该程序服务于多个目的:
    • 展示网络端点的状态
    • 展示某个主机上各个接口所属的多播组。-ia标志是展露多播组的通常方式
    • 使用-s选项显示各个协议的统计信息
    • 使用-r选项显示路由表或使用-i选项显示接口信息

1.3

  • lsof程序,名字lsof代表“列出打开的文件(list open files”。与tcpdump一样,losf也是一个公开可得的方便调试的工具
  • losf的常见用途之一是,找出哪个进程在指定的IP地址或端口上打开了一个套接字。netstat告诉我们那些IP地址和端口正在使用中以及各个TCP连接的状态,却没有标识相应的进程。losf弥补了这个缺陷
  • 该程序的常见用途之一是:如果在启动一个捆绑其众所周知端口的服务器时得到改地址已在使用的出错消息,那么我们可以使用losf找出正在使用该端口的进程