"Unix网络编程:套接字API"

  "Unix网络编程"

Posted by Xu on May 7, 2018

Unix网络编程:套接字API

第二章 传输层TCP,UDP和SCTP

TCP/IP协议概貌:

unix_socket_1

2.1SCTP:流控制传输协议

SCTP在客户和服务器之间提供关联,并像TCP那样给应用层提供可靠性,排序,流量控制以及全双工的数据传输服务。SCTP使用“关联”取代“连接”是为了避免:一个连接只涉及到两个IP地址之间的通信,一个关联指代可能因为多宿而涉及不止一个地址的两个系统之间的一次通信回话。

与TCP不同在于:

  • SCTP面向消息
  • SCTP能够在所连接的端点之间提供多个流
  • SCTP提供多宿特性

2.2TCP连接的建立和终止

这一小节帮助大家理解connect,accept,close函数并使用netstat调试TCP应用程序。

三次握手

  1. 服务器端必须准备好接受外来的连接,这通过调用socket、bind和listen来完成,称为 “被动打开”
  2. 客户端通过connect进行”主动打开”,这将引起客户端向服务器端发送一个SYN报文段.告诉服务器初始序列号,SYN一般只含有一个IP头部,一个TCP头部,及可能有的TCP选项。
  3. 服务器端必须确认SYN,同时自己也得发一个SYN,SYN和ACK合并在一个报文段发送
  4. 客户端必须确认服务器SYN

unix_socket_2

TCP选项

  • MSS选项:TCP发送的SYN报文段中包含这个选项表示,通知服务器它的最大报文段大小为MSS
  • 窗口规模大小:流量窗口,最大为65535,因为TCP头部只占16位
  • 时间戳大小

TCP连接关闭

unix_socket_3

TCP状态转换图

unix_socket_4

unix_socket_5

TIME_WAIT的作用:

TIME_WAIT一般维持2MSL(报文段生命期*2)

  • 可靠实现TCP全双工连接的终止:假设最后客户端发送的ACK丢失,服务器端将重发最终的FIN,客户端要一直维护这种可以接受FIN的状态,然后重发ACK。否则它将响应一个RST报文段,因为如果没有TIME_WAIT则认为客户端该连接已经彻底关闭。
  • 允许老的重复报文段在网络中消逝,在TIME_WAIT阶段防止客户端发起该关闭连接的化身(IP地址和端口号相同的连接)。因为如果出现该连接,可能之前 “迷途漫游”的报文段(ACK,FIN)会到达该连接导致,该化身接收到错误的报文段。这也就是为什么要设置2MSL,使得两端的迷途漫游的报文段会在网络中消逝

2.3SCTP关联的建立和终止

四次握手

  1. 同样服务器通过socket,bind,listen函数作为接受连接的准备
  2. 客户通过调用connect发送一个SCTP的INIT消息(包括IP地址清单,初始序列号,起始标志…)
  3. 服务器端对INIT消息发送一个ACK,ACK包含(服务器IP地址清单,初始序列号,起始标志,以及一个state cookie
  4. 客户端尝试根据cookie来发送信息,发送一个COOKIE ECHO消息来尝试进行带有cookie的数据发送。
  5. 服务器端根据客户端发送过来的COOKIE来ACK消息确认cookie是否正确

unix_socket_6

关联终止

  • SCTP不像TCP那样允许半关闭的关联,当一端关闭某个关联时,另一端必须停止从应用进程传入新的用户数据并发送。
  • 关联关闭请求的接受端只将已经排队的数据发送完后,执行关联的关闭

unix_socket_7

状态转换图

unix_socket_8

unix_socket_9

2.4 套接字端口号与并发服务器

当服务器通过调用accept创建新的套接字与客户端建立连接并调用fork来处理客户端数据,当有多个客户同时和服务器建立请求后,会有多个子进程和多个套接字来处理连接中的数据。但这些套接字都是通过相同的端口进行通信,端口是如何通过数据信息来将每个连接的数据转发到对应的套接字fd中进行通信的?这就是通过套接字对来进行连接数据的分流

  • 每个已连接的套接字都保存一组套接字对信息,其中包括源主机IP地址(一个服务器端可能有多个IP地址供不同进程使用,多宿)及端口信息,还包括对端地址的端口信息
  • {12.106.32.254:21,206.168.112.219:1500},其中12.106.32.254:21为源机的IP地址和端口号,206.168.112.219:1500为目的端IP地址和端口号。
  • 还可以使用通配符来表示{*:21,* :* },这就表示监听21端口来自任何IP地址的数据。

unix_socket_26

2.5 缓冲区大小及限制

IPv4

  • 1.IPv4最大大小是2^16(65535,包括IPv4首部,实际载荷为65535-20)

  • 2.IPV4最小链路MTU为68,因为IPv4首部=20字节+40多字节的选项部分
  • 3.路径MTU:两个主机之间的路径中最小的MTU
  • 4.分片:这些分片在到达最终目的之前通常不重组
    • IPv4与IPv6的区别:
      • IPv4:主机对其产生和路由对其转发的数据报进行分片
      • IPv6:主机对其产生的数据报分片,路由不对其转发的数据报进行分片(路由对其产生的还是会分片的)
    • DF位:置位表示,不允许分片
      • IPv4:当路由器收到一个超出其外出链路MTU大小且设置了DF位的IPv4数据报时,将产生一个ICMPv4(目的不可达,需分片但DF已置位)
      • IPv6:隐含一个DF位(IPv6本来就不允许路由转发时分片),将产生一个ICMPv6(分组太大)
  • 5.DF位的作用:发现路径MTU
  • 7.最小重组缓冲区大小,表示IP的任何实现版本都必须保证的最小数据报大小(即双方还没开始交换MSS大小)
    • IPv4:576字节
    • IPv6:1500字节
  • 8.MSS:用于向对端TCP通告对端在每个分节中能发送的最大TCP数据量,目的是告诉对端其重组缓冲区大小的实际值,从而避免分片
    • 通常值:MTU-IP首部固定长度-TCP首部固定长度(以太网为:1500-20-20=1460)
    • 实现:通过SYN分节(第一次和第二次握手)上的MSS选项设置
    • 最大值:2^16(65535)字节

2.6 TCP输出

unix_socket_27

这里描述的是应用进程写数据到一个TCP套接字中时发生的步骤。

这里涉及以下知识点:

  • 1.每一个TCP套接字都有一个发送缓冲区,由SO_SNDBUF更改大小
  • 2.write调用发生的事情:
    • 内核从应用进程的缓冲区中复制所有数据写到套接字的发送缓冲区
    • 如果套接字缓冲区无法容下,可能原因:
      • 应用进程的缓冲区大于套接字的发送缓冲区
      • 套接字的发送缓冲区已有其他数据
    • 如果无法容下,进程被睡眠
    • 内核不从write返回(假设是阻塞的套接字) ,直到应用进程的缓冲区所有的数据都复制到套接字缓冲区中
    • 如果write返回,表示当前进程数据已经复制到套接字缓冲区,进程缓冲区可继续使用。但不表示对端已经收到
  • 3.TCP套接字缓冲区需要保留已经发送的数据,直到收到该数据的ACK

2.7 UDP输出

unix_socket_28

这里描述的是应用进程写数据到一个UDP套接字中时发生的步骤。

这里涉及以下知识点:

  • 1.UDP有发送缓冲区(通过SO_SNDBUF设置),实际上不存在(因此上图用虚线),它表示的只是该UDP套接字的数据报上限
  • 2.应用程序写一个大于套接字发送缓冲区大小的数据报,内核返回进程EMSGSIZE错误
  • 3.因UDP不可靠,不必保留应用进程的数据副本(和TCP的区别),因此无需一个真正的发送缓冲区
  • 4.数据保存的过程:应用进程的数据在沿协议向下传递时,通常被复制到某种格式的一个内核缓冲区中,当数据发送后,副本被链路层丢弃
  • 5.UDP的write成功返回表示所写的数据报或其所有分段已经被加入数据链路层的输出队列
  • 6.如果链路层输出队列没有足够的空间存放数据报或分段,返回ENOBUFS错误到进程

第三章 套接字编程

3.1 套接字地址结构:

IPv4地址结构(16个字节):

struct in_addr{
    in_addr_t s_addr;//32bit IPv4地址
};
struct sockaddr_in{
    unit8_t sin_len;//Ipv4地址结构长度
    sa_family_t sin_family;//Ipv4地址族:AF_INET,posix
    in_port_t sin_port;//16位表示端口号,posix
    struct in_addr sin_addr;//表示地址,posix
    char sin_zero[8];//暂不使用
};

通用套接字地址结构

我们将套接字地址传递给套接口函数时,总是通过指针来传递,即传递的是一个指向结构的指针。

为了兼容Ipv4,unix域,数据链路等不同协议类型的socket地址,定义了一个通用地址结构来存储这些地址信息:

struct sockaddr {
    unit8_t sa_len;//协议长度
    sa_family_t sa_family;//协议类型
    char sa_data[14];//协议所指向的地址

}

从应用程序开发人员的观点看,这些通用的套接字结构的唯一用途就是给指向特定协议的地址结构的指针转换类型

IPv6地址结构(28个字节):

struct in6_addr{
    unit8_t s6_addr[16];//16*8=128bit的地址结构
}
#define SIN6_LEN

struct sockaddr_in6{
    unit8_t sin6_len;//Ipv6地址结构长度
    sa_family_t sin6_family;//Ipv6地址结构的地址族:AF_INET6,posix
    sa_family_t sin6_port;//16位表示端口号,posix

    uint32_t sin6_flowinfo;//流信息:低20位是流标签,高12位保留
    struct in6_addr sin6_addr;//表示地址,posix
    uint32_t sin6_scope_id;//域接口集合
};

套接字地址结构比较

unix_socket_10

3.2 值-结果参数

  1. 进程到内核传递套接字地址结构的有三个函数:bing,connect,sendto

     struct sockaddr_in serv;
     connect(sockfd,(SA*) &serv,sizeof(serv));//传递套接字地址结构地址,和该结构数据的长度
    
  2. 内核到进程传递套接字地址结构的函数有四个:accept,recvfrom,getsockname,getpeername

     struct sockaddr_un cli;
     socklen_t len;
     len = sizeof(cli);
     getpeername(unixfd,(SA*)&cli,&len);//传递一个套接字地址结构对象进去cli,并告诉内核该结构的数据长度为len ,内核返回函数想要的地址结构到cli,并告诉进程该地址结构的数据长度,同样写到&len
    
    • 类似上面这种,参数将一定信息传入函数,函数返回时又将结果返回到参数数据中的类型就叫做值-结果参数
    • 这种具有值-结果参数的函数还有:select,getsockopt,recvmsg,ifconf,sysctl

3.3 字节排序函数

对于一个整数,内存中存储这两个字节又两种办法:

  • 一种是将低字节存储在起始地址,称为小端字节序
  • 另一种方法是将高序字节存储在起始地址。称为大端字节序

  • 主机字节序:对于某一个系统内部使用的字节序叫做主机字节序,系统使用大端字节序还是小端字节序并没有标准。
  • 网络字节序 :网际协议使用的字节序是网络字节序,网际协议在处理这些数据的时候使用的是大端字节序

为了将网络字节序和主机字节序进行协调,然后转换,我们定义如下四个函数来进行字节序的转换:

#include <netinet/in.h>

//主机字节序向网络字节序进行转换
unit16_t htons(unit16_t host16bitvalue);
unit32_t htons(unit32_t host32bitvalue);

//h代表主机host,n代表网络net,s代表短整形short,l代表长整型long

//网络字节序向主机字节序进行转换
unit16_t ntohs(unit16_t net16bitvalue);
unit32_t ntohs(unit32_t net32bitvalue);

3.4 字节操作函数

对字节进行设置,拷贝,比较操作的有两组函数:

  • 一组以b开头(byte)
  • 一组以mem开头(memory)
#include <strings.h>
void bzero(void *dest,size_t nbytes);//将目标中的指定数目字节置为0
void bcopy(const void *src,void *dest,size_t nbytes);//将指定数目的字节从源地址拷贝到目标地址
void bcmp(const void *ptr1,const void *ptr2,size_t nbytes);//比较两个字节串,相同返回0,否则返回非0

#include<string.h>

void *memset(void *dest,int c,size_t len);//将目标指定数目字节置为c
void *memcpy(void *dest,const const void *src,size_t nbytes);//将源地址的指定数目的字节拷贝到目标。
void *memcmp(const void *ptr1,const void *ptr2,size_t nbytes)//同bcmp,但ptr1>ptr2返回大于0的数,ptr1<ptr2返回小于0的数。

  • memcpy的参数顺序和赋值语句相同:dst = src;
  • memset最后两个参数的顺序:memXXX的函数都要求有一个长度,且它总是最后一个参数

3.5 网络地址转换函数

这一组函数将网络字节序的二进制值和ASCLL字符串(易读性)之间进行转换:

#include <arpa/inet.h>
//a代表ascll码,n代表网络字节序的二进制

int inet_aton(const char *strptr,struct in_addr *addrptr);//将strptr指向的C字符串"206.168.112.96"转换为网络字节序二进制值到addrptr存储
//返回1时,串有效,返回0,串有错

in_addr_t inet_addr(const char *strptr);//同上,进行字符串到二进制的转换,直接返回结果32位二进制的值。
//不推荐使用

char *inet_ntoa(struct in_addr inaddr);//将二进制值转化为十进制的ASCLL码字符串,参数并非是指向结构的指针,而是结构本身
//返回转换后的字符串
  • inet_addr函数和inet_aton的区别在于,当转换出错时,inet_addr会返回一个全为1的32位二进制值,所以我们不能用inet_addr处理十进制字符串“255.255.255.255”广播地址

还有一组比较新的网络地址转换函数:inet_pton,inet_ntop(n代表numeric,p代表presentation(ASCLL串))

#incldue<arpa/inet.h>

int inet_pton(int family,const char *strptr,void *addrptr);//将ASCLL串转换为二进制,存放到addrptr

char *inet_pton(int family,void *addrptr,const char *strptr,size_t len);//将二进制addrptr转换为ASCLL串返回,也就是存放在strptr中

地址转换函数总结:

unix_socket_11

  • sock_ntop():由于inet_pton和inet_pton都需要协议相关的信息,还有特定的地址结构,会使我们的代码具有局限性,不具有通用性,为了解决这个问题,我们引入sock_ntop()函数来使用通用地址结构作参数,然后分析地址结构信息调用具体的地址转换函数:
#include "unp.h"

char *sock_ntop(const struct sockaddr *sockaddr,socklen_t addrlen);//使用通用地址结构sockaddr,函数内部,对该地址结构信息进行分析然后调用对应的地址转换函数

3.6 对字节流套接口上的读写操作

  • 字节流 套接字上的读read写write操作所表现出的行为 不同于通常的文件I/O。字节流套接字上的读或写输入或输出的字节数可能比要求的数量少,这是因为内核中套接口的 缓冲区可能已经达到上限,此时需要调用者 再次调用read和write函数来输入或输出剩余的字节。

我们对字节流套接口进行读或写操作时调用下面的三个函数:

ssize_t readn(int fd,void *buff,size_t nbytes);//从套接字读取指定数目的字节到buff中
ssize_t writen(int fd,const void *buff,size_t nbytes);//将buff中的数据写入指定数目的字节到fd的文件中
ssize_t readline(int fd,void *buff,size_t maxlen);//逐行读取数据到buff中,但读取的最大数目字节数为maxlen

  • readn和writen都是对read和write进行封装,通过重复调用read和write来读取或写入指定数目的字节内容
  • readline则是调用read来一个一个字节读取,碰到’\n’或达到maxlen长度停止读取数据,因为对read多次重复调用,所以效率非常低
  • 这三个函数会对EINTR(系统调用被一个捕获的信号中断)进行检查,发生这种错误继续进行读写操作

unix_socket_12 unix_socket_13 unix_socket_14

第四章.基本TCP套接口编程

  • 本部分讲解编写一个完整的TCP客户和服务器程序所需要的基本套接口函数。
  • 还包括并发服务器,是在同时有大量的客户连接到同一服务器上时用于提供并发性的一种常用的Unix

4.1 socket函数

一个进程想要指向网络I/O,第一件事就是要调用socket函数创建套接字用于进行网络I/O的接口。

#incldue<sys/socket.h>
int socket(int family,int type,int protocol);//创建成功时返回套接字文件的文件描述符fd
  • family指协议族,AF_INET,AF_INET6等
  • type表示socket套接字的类型:SOCK_STREAM(字节流),SOCK_DGRAM(数据报)等
  • protocol表示传输层协议:IPPROTO_TCP,IPPROTO_UDP,IPPROTO_SCTP

基于TCP客户/服务器端套接口函数简介:

unix_socket_15

family,type以及protocol可选项:

unix_socket_16 unix_socket_17

4.2 connect函数

TCP客户用connect函数来建立与TCP服务器的连接:

#include<sys/socket.h>

int connect(int sockfd,const struct sockaddr *servaddr,socklen_t addrlen);//sockfd为我们创建的套接字文件描述符,servaddr为我们想要连接的服务器端的通用地址结构,addrlen为该地址的数据长度。
  • 客户在调用函数connect前并不必非得调用bind函数,内核会确定源IP地址,并选择一个临时端口作为源端口
  • 如果是TCP套接字,则connect会触发三次握手

connect函数可能会出现如下三种情况:

  1. ETIMEDOUT错误:超时,没有收到SYN的响应,4.4BSD分4s,24s,75s三次间隔发送仍未收到响应报该错误
  2. RST:复位表示服务器并没有进程监听和等待与客户端进行连接
  3. ICMP:目的地不可达,软错误,同样根据4,24,75间隔重复发送SYN。

4.3 bind函数

bind函数把一个本地协议地址赋予一个套接口,对于网际协议,协议地址就是32位IPv4或128位的IPv6地址与16位的TCP或UDP端口号的组合。

#include<sys/socket.h>

int bind(int sockfd,const struct sockaddr *myaddr,socklen_t addrlen);

//sockfd指对应的套接字文件描述符,myaddr指我们要绑定的协议地址,addrlen表示该地址的数据长度
  • bind函数可以指定一个端口号,也可以指定一个IP地址,可以两个都指定,也可以两个都不指定。当IP地址信息为通配地址时,或指定端口号为0时,内核会给该套接字设置临时端口,并且只有当套接字发出数据时才选择一个本地的IP地址。

unix_socket_18

  • 当bind不指定IP地址和端口时,内核会临时分配一个,但分配的端口值并不能被返回,因为参数myaddr是const类型,所以我们要想获取该临时端口号,需要使用getsockname函数来获取宿IP协议地址信息。
  • bind函数常返回的一个常见错误为EADDRINUSE(地址已经被使用)

4.4 listen函数

  • 当socket创建套接字时,它被假设为一个 主动套接口,也就是默认要调用connect发起连接
  • listen函数把一个 主动套接口转换成一个 被动套接口
#include <sys/socket.h>

int listen(int sockfd,int backlog);//sockfd指要转换为被动套接字的文件描述符,backlog表示该套接字排队的最大连接个数

backlog涉及到的两个队列:

  1. 未完成连接队列:客户端已经发送一个SYN到服务器端,服务器端接收到该SYN后就会在未完成队列创建一项,然后该套接口处于SYN_RCVD状态,并响应三次握手中第二个报文段。
  2. 已完成连接队列:每个已完成三次握手过程的套接字,都会位于该队列,这些套接字都处于ESTABLISHED状态

unix_socket_19 unix_socket_20

  • backlog曾设置为这两个队列总和的最大值,但不同的系统有不同的实现方式,并且不要把backlog设置为0.
  • 未完成连接中队列的任何一项在其中存留时间不超过RTT
  • 当一个客户的SYN到达时,若这些队列是满的,TCP则忽略该SYN报文段,会自动出发TCP的自动重传机制

4.5 accept函数

用于从已完成队列队头返回下一个已完成连接,如果已完成连接队列为空,进程被投入睡眠。

#include<sys/socket.h>

int accept(int sockfd,struct sockaddr *cliaddr,socklen_t *addrlen);//值-结果参数

  • 参数cliaddr和addrlen用来返回内核分配的已连接的客户端的协议地址及长度信息。返回值是内核自动生成的一个全新的描述符,用于与客户进行TCP连接。
  • 返回结果包括
    • 新套接字描述符或错误代码
    • cliaddr客户端协议地址信息
    • addrlen协议地址数据长度
  • 如果我们队客户端的协议地址信息不感兴趣,可以将cliaddr和addrlen设置为空指针

4.6 fork和exec函数

fork函数

在介绍并发服务器之前,要先介绍一下Unix的fork函数

#include <unistd.h>
pid_t fork(void);
  • 调用一次fork,返回两次,在父进程中返回子进程的pid,在子进程中返回0(因为子进程可以轻松通过getppid去的父进程pid)
  • 父进程中调用fork之前的所有描述符在fork返回之后有子进程分享,网络服务器中,通常,子进程对一个已连接套接口继续进行读写,而父进程则关闭这个已连接的套接口。

fork的两种典型用法:

  1. 一个进程创建一个自身的拷贝,每个拷贝都可以在另一个拷贝执行其他任务的同时处理各自的操作
  2. 一个进程想要执行另一个程序,但创建新进程只能通过调用fork,所有fork先创建一个自身的拷贝,然后调用exec来执行新的程序。

exec函数

存放在硬盘上的可执行程序能够被Unix执行的唯一方法是,由一个现有进程调用六个exec函数中的一个,exec会将当前进程映像替换成新的程序文件,该程序通常从main开始执行,进程ID不变

unix_socket_21 unix_socket_22

六个exec函数中只有execve是内核调用,其他五个都是调用execve的库函数:

unix_socket_23

4.7 并发服务器

我们希望一个服务器不长时间被单个客户长期占用,而是希望 同时服务多个客户,Unix编写并发服务器的最简单的办法就是fork一个子进程来服务每个客户。这样就使得父进程仅为监听进程子进程来提供对客户的服务。

  • 父进程会关闭accept打开的已连接文件描述符
  • 子进程则关闭用于连接请求监听的文件描述符
  • 这样父子进程分工明确,父进程监听请求,子进程处理客户数据。
//并发服务器编程模版:

pid_t pid;
int listenfd,connfd;
listenfd = Socket(...);//创建一个套接字
Bind(listenfd);//将套接字绑定到指定的协议地址
Listen(listenfd,LISTENQ);//将套接字由主动转换为被动开始监听。
for(;;)
    connfd Accept(listenfd,..);//从已完成队列中取出一个连接分配一个套接字,并返回文件描述符
    if((pid = Fork())==0){
        //进入子进程服务客户
        Close(listenfd);//首先关闭监听套接字,仅让父进程监听
        doit(connfd);//处理连接传送过来的数据
        Close(connfd);//处理完毕后关闭该套接字
        exit(0);//子进程退出
    }
    Close(connfd);//父进程关闭已连接套接字,仅让子进程去处理连接中客户传送过来的数据

问题:为什么父进程关闭connfd,子进程关闭listenfd都没有将这两个套接字关闭。

  • 因为每个文件或套接字在 文件表项中,都有一个 引用计数,当fork一个子进程后,listenfd和connfd的引用计数都变为了2,所以关闭一个,其引用计数减为1,只有当引用计数减为0时,该套接字才会关闭

unix_socket_24

4.8 close函数

#include <unistd.h>

int close(int sockfd);//导致相应描述符的引用计数值减1,当引用计数减为0时触发四次挥手

如果想要直接通过socket发送FIN来关闭连接,不通过引用计数,则可以改用shutdown函数

4.9 getsockname和getpeername函数

#include <sys/socket.h>
//均为值-结果参数

int getsockname(int sockfd,struct sockaddr *localaddr,socklen_t *addrlen);

int getpeername(int sockfd,struct sockaddr *peeraddr,socklen_t *addrlen);

这两个函数都是获取指定套接字的协议地址信息,存放到localaddr和peeraddr,使用场景有如下几种:

  1. 没有bind就直接发起connect的套接字,getsockname来返回**内核给套接字在连接中赋予的IP地址和临时端口号 **
  2. 通配IP地址或端口号0来绑定的套接字
  3. getsockname获取套接字的地址族
  4. 当服务器调用accept的某个进程通过调用exec更换程序时,由于进程映像全部被替换,accept获取的“对端地址”信息也随之丢失,所以我们要获取该已连接套接字的”对端地址”则需要调用getpeername来获取。

unix_socket_25

第五章.TCP客户/服务器的代码实例

这一章我们将实行一个完整的TCP客户端/服务器端程序的例子。

  1. 客户从标准输入和输出读入一行文本,并写给服务器
  2. 服务器从网络输入读入这行文本,回射给客户
  3. 客户从网络输入读入这行回射文本,并显示在标准输出上

5.1 TCP服务器程序

进行网络通信的第一步是要启动服务器,这里我们编写了一个并发服务器程序。该程序主要分为5个步骤:

  1. 创建监听套接字socket
  2. 设置协议地址信息,并将该协议地址绑定到该套接字上
  3. 将套接字转换为被动套接字
  4. 处理套接字的连接请求
  5. fork子进程来处理客户端数据(包括回射客户端str_echo)

unix_socket_29

服务器回射程序str_echo,来和客户端通信:

unix_socket_30

5.2 TCP客户端程序

服务器启动后,开始监听请求连接,接下来我们看客户端是如何发起连接请求的:

客户端主要做了三件事:

  1. 创建客户端用于TCP连接的socket
  2. 根据参数配置协议地址信息后,发起请求连接connect,这里没有进行bind操作
  3. 向连接成功的套接字发送数据

unix_socket_31

其中通过已连接的套接字给服务器端发送数据的函数str_cli代码如下:

unix_socket_32

5.3 正常启动

  1. 首先本地启动服务器:
     linux$ tcpserver &  //阻塞在accept调用
    
  2. 查看服务器启动后的监听套接字: unix_socket_33

  3. 启动客户端:
     linux$ tcpcli 127.0.0.1  //阻塞在fgets调用
    
  4. 查看端口状态 unix_socket_34

  5. 查看这些进程的父子关系: unix_socket_35

5.4 正常终止

我们可以在客户端键入EOF(Control+D)来终止客户端进程,终止后立即调用netstat命令可以看到客户端进程处于TIME_WAIT状态。

  • 客户端程序中fgets会返回空指针,从而离开函数str_cli(),并执行exit(0),关闭客户端进程
  • 客户端进程终止处理的其中一个步骤就是关闭该进程相关的套接字,导致TCP客户端发送一个FIN信号到服务器端
  • 服务器端TCP接收到FIN报文段时,readline函数会返回0,同样跳出子进程中的处理函数str_echo(),并执行exit()退出子进程,服务器端的套接字被关闭。
  • 客户端接收到服务器端最后一个ACK后处于TIME_WAIT状态(2RTT)
  • 服务器端子进程退出后会向父进程发送SIGCHLD信号,虽然该信号产生了,但本服务器代码并没有处理该信号,父进程没有处理导致子进程成为了僵死进程(Z:没有将进程退出时的信息进行回收)

5.5 POSIX信号处理

信号就是通知某个进程发生了某个事件,有时也称为软件中断,并且是异步发生的

信号可以:

  1. 由一个进程发给另一个进程(可以是自身)
  2. 内核发给某个进程

每个信号都有一个与之关联的处置,也称为行为,我们通过调用sigaction函数来设定信号的处理过程:

  1. 我们可以捕获信号,并调用该信号所设置的信号处理函数,这称为捕获信号,但有两种信号不能被捕获:SIGKILL和SIGSTOP
  2. 可以将信号设置为SIG_IGN来忽略该信号,同样,SIGKILL和SIGSTOP不能被忽略
  3. 将信号设置为SIG_DFL,启用默认的信号处理函数

signal函数

  • 建立信号处置的POSIX方法就是调用sigaction函数,但这个过程比较复杂,因为我们需要创建并填写对应的sigaction结构。
  • 另一个方法是调用signal函数,两个参数一个是信号名,另一个则是信号处理函数。调用简单
  • 其实目前大部分定义signal函数就是通过内部创建sigaction结构体,然后调用sigaction函数来实现,如下图所示:

    unix_socket_36

  1. 上图所示的Sigfunc是通过typedef定义的:typedef void Sigfunc(int);//定义Sigfunc为参数为整型数,函数没有返回值的函数原型,这样可以简化函数的书写
  2. 设置sigaction的sa_handler元素设置为传进来的函数指针
  3. 第7行设置信号掩码,POSIX允许我们设置这样一组信号,他们在信号处理函数被调用期间阻塞,不能传递给进程。这里是将sa_mask设置为空集,也就是不阻塞任何信号(但POSIX保证阻塞该信号处理函数所处理的信号不能再次传递给进程)
  4. 设置SA_RESTART标志:8~17行,设置该标志,由相应信号而中断的系统调用将由内核自动重启。对SIGALARM进行特殊处理:因为该信号通常是对I/O操作设置超时的操作,发生超时时,我们希望该系统调用中断掉。所以有和SA_RESTART互补的SA_INTERRUPT标志
  5. 调用sigaction设置信号的处理动作,并返回旧的处理动作到oact,然后返回旧的信号处理函数

信号处理语义

  1. 信号处理函数一旦被安装就一直被安装
  2. 信号处理函数期间,正在被递交和sa_mask信号集中的信号被阻塞
  3. 一个信号在被阻塞期间产生多次,当被解阻塞后,只会递交一次,信号默认是不排队
  4. 利用sigprocmask来阻塞和解阻塞一组信号

5.5 处理SIGCHLD信号

  • 僵死进程:当子进程退出时,会给父进程发送SIGCHLD信号,子进程会处于僵死状态维护进程信息,这些信息包括子进程的进程ID,终止状态以及资源的利用信息(CPU时间,内存使用量等)。
  • 当一个进程终止时,操作系统会检查该进程的子进程是否拥有僵死进程,如果有,则将这些僵死进程的父进程ID 设置为1,由Init进程来清理这些僵死进程的信息(init会wait这些僵死进程)

处理僵死进程

  • 僵死进程的存在会占用内存空间,并且可能导致我们耗尽进程资源。
  • 所以无论何时我们fork子进程都要wait它们,防止它们变成僵死进程。
  • 解决方案就是通过在父进程捕获SIGCHLD信号的处理函数,函数体中调用wait()来回收子进程中的信息
Signal(SIGCHLD,sig_chld);//设置SIGCHLD处理函数

#include "unp.h"

void sig_chld(int signo){
    pid_t pid;
    int stat;
    pid = wait(&stat);//wait调用获取到子进程的pid和终止状态stat
    printf("child %d terminated\n",pid);
    return;

}

  • 我们服务器的父程序中阻塞在accept()慢系统调用(slow system call)中,捕获到SIGCHLD信号,致使accept返回一个EINTR错误(系统调用被中断),而父进程程序没有处理该错误,导致进程终止。
  • 有的系统会设置SA_RESTART标志,导致该信号触发时,阻塞进程的系统调用accept会自动重启,而不会返回错误后终止。
  • 所以有些系统对于阻塞于某个慢系统调用的一个进程捕获某个信号且相应处理函数返回时,该系统调用会返回EINTR错误,而有的系统则自动重启该系统调用

5.6 wait和waitpid函数

#include <sys/wait.h>

pid_t wait(int statloc);

pid_t waitpid(pid_t pid,int* statloc,int options);

相同点:

  • 都等待子进程终止然后返回终止子进程的pid及子进程的终止状态(正常终止,由某个信号杀死,作业控制停止)。
  • 有些宏(WIFEXITED,WEXITSTATUS)的设置来获取子进程的退出状态,杀死子进程的信号值,停止子进程作业控制的信号值。

区别:

  • wait:不指定子进程,在父进程中等待任一子进程终止为止,且会 一直阻塞
  • waitpid:等待 指定子进程终止,才进行处理,通过pid参数进行指定,当pid为-1时,不指定子进程,第一个子进程终止时就处理。同时可以通过设置参数option(WNOHANG),等待子进程时不阻塞父进程。

多个信号同时递交

  1. 当一个服务器进程有多个子进程来服务多个客户时,当多个客户(如5个)同时终止,服务子进程也会终止并发出5个SIGCHLD信号
  2. 当我们在父进程调用 wait()函数时,处理第一个SIGCHLD信号时调用信号处理函数,该函数执行完毕之前收到其它四个子进程发出的SIGCHLD信号
  3. 由于信号不排队,所以其他四个信号都会消失。导致这四个进程变为僵死进程

unix_socket_37 解决办法:使用waitpid函数,我们在一个循环内调用waitpid,获取终止的子进程的状态。因为waitpid可以不用阻塞,当waitpid调用信号处理函数时,循环依然继续监听子进程是否终止。

服务器端的正确代码: unix_socket_38

unix_socket_39

accept返回前连接夭折

TCP三次握手的连接已经建立,TCP却发送了一个RST复位,也就是当该连接已经进入已完成队列,等待服务器调用accept。这时客户端发送一个RST信号,随后服务器进程调用accept。这会引发accept报错。

服务器子进程终止

当客户/服务器对启动后,然后杀死服务器子进程,我们观察客户端发生的动作:

  1. 正常情况下,客户端给服务器端发送一行文本,服务器端回射给客户端显示在终端。
  2. 当服务器处理该客户的子进程被kill掉后,服务器会向客户端发送FIN信号,客户端TCP会立即响应ACK
  3. SIGCHLD信号发送给服务器父进程,调用对应的处理函数
  4. 此时客户端正阻塞在fgets系统调用上,且客户端TCP处于CLOSE_WAIT状态,服务器端TCP位于FIN_WAIT2状态
  5. 当客户端输入一行文本,客户端依然可以发送给服务器,因为客户TCP接收到FIN只是表示服务器进程关闭了连接的服务器端(也就是服务器不会再向客户端发送数据),并没有告诉客户端说服务器进程已经终止(这个例子中,服务器进程是被kill掉的,已经终止,如果没有被kill是可以发送数据的)。所以服务器接收到该数据后,会返回一个RST报文段
  6. 客户端看不到这个RST信号,因为当执行到Readline时,会读取到第2步服务器发送过来的FIN报文段从而返回0,报错“server terminated prematurely”
  7. 客户端进程退出,关闭所有套接字

unix_socket_40

SIGPIPE信号

  • 上述服务器子进程终止的场景下,要是我们连续两次写数据到服务器端,第一次会触发服务器发送RST报文段,第二次写则是 向某个已经收到RST的套接字执行写操作
  • 这时,内核向该进程发送一个 SIGPIPE信号,该信号缺省行为是 终止进程,并返回EPIPE错误

unix_socket_41

服务器主机崩溃

在客户端服务器端启动后,然后从网络上断开服务器主机,然后客户端键入文本数据。

此时发生的情况如下:

  1. 这里我们假设服务器主机崩溃并不是shutdown。也就是服务器端网络不可达
  2. 客户端发送文本数据到服务器端,并阻塞在readline 系统调用,等待回射的应答。
  3. 客户TCP会执行重传机制,不断向网络重传数据报文段,Berkeley实现的重传要重传12次,共等待9min才放弃重传,放弃重传后,readline会返回**ETIMED-OUT(超时错误),如果中间有路由器判定服务器主机不可达,则响应“destination unreachable”ICMP消息,返回EHOSTUNREACH或ENETUNREACH错误 **
  • 如果我们不想等待9min才知道服务器崩溃,我们可以 设置readline超时
  • 上述是我们 向服务器发送数据时,我们才能知道服务器崩溃,为了不用发送数据就能感知到服务器崩溃,我们可以给套接字设置 SO_KEEPALIVE套接口选项。

服务器主机崩溃后重启

假设,服务器端崩溃后重启,客户端并没有感知到服务器关闭的过程,这里假设没有使用SO_KEEPALIVE套接口选项,所以客户端不发送数据就不能感知到服务器的关闭,发生如下步骤:

  1. 服务器和客户端正常连接
  2. 服务器崩溃并重启
  3. 客户端输入一行文本数据,向服务器端发送对应报文段
  4. 服务器崩溃后重启,它的TCP之前的连接信息全部丢失,所以服务器端对接收到的报文段响应RST
  5. 客户端接收到RST响应,被阻塞的readline系统调用返回ECONNRESET错误

服务器主机关机

Unix系统关机时,init进程会给所有进程发送SIGTERM信号,该信号可以被捕获,再等待一段固定的时间(往往在5~20s之间)然后给所有仍在运行的进程发送SIGKILL信号,该信号不能被捕获。这么做是为了给进程留一小段时间来清除和终止,当服务器子进程终止时,所有套接字都将关闭

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

从上一章节的例子中,客户端阻塞在fgets系统调用,当服务器端子进程被kill掉时,给客户端发送了一个FIN报文段,而readline不能及时读取,所以这样的场景下需要进程有一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪,它就通知进程。这个能力就叫做 I/O复用

I/O复用的场景:

  • 客户端处理多个描述符
  • 客户端处理多个套接字
  • TCP服务器即要处理监听套接口,又要处理已连接套接口
  • 服务器即要处理TCP又要处理UDP
  • 服务器要处理多个服务或多个协议(inetd守护进程)

6.1 I/O模型

  • 阻塞I/O模型:recvfrom,当没有数据报到达时,进程一直阻塞等待
  • 非阻塞I/O模型:recvfrom轮询,循环询问数据报是否准备好。如果没有准备好,返回EWOULDBLOCK错误
  • I/O复用模型:可以等待多个描述符就绪,阻塞在select调用上,而不是单个描述符的recvfrom上。
  • 信号驱动I/O模型:当描述符就绪时发送SIGIO信号通知进程,并通过sifaction系统调用安装信号处理函数。
  • 异步I/O模型:告诉内核启动一个I/O操作,并让内核在整个I/O操作完成后通知我们,信号驱动I/O是通知我们开始I/O操作,异步I/O则是由 内核通知我们I/O操作何时完成
    • 除了异步I/O操作,其它都是同步I/O操作。异步和同步的区别在于:真正执行I/O读写时,进程是否堵塞

unix_socket_42

6.2 select:函数细节详见blog:高级I/O

blog:http://blog.xbblfz.site/2018/04/15/高级i-o/

目前使用select技术修改客户端发送数据的代码:

客户端的套接口上三个条件处理如下:

  1. 对端TCP发送数据,套接口可读,read返回一个大于0的数
  2. 对端TCP发送一个FIN套接口可读,read返回0
  3. 对端TCP发送一个RST套接口可读,read返回-1,且将errno设置为对应错误码

unix_socket_43

6.3 批量输入

问题:当我们的标准输入批量发送文本数据(多行)到服务器端,采用5.2中的代码,我们发现客户端接收服务器端回射的输出文件总是小于输入文件。(readline和fgets)

原因:因为缓冲区的存在使得I/O问题更加复杂:

  1. 当服务器端发送一个FIN到客户端时,客户端不应该直接退出将套接字的读写端全部关闭,因为客户端TCP缓冲区可能还有数据没有发送到服务器端,所以我们需要一种关闭TCP连接一半的方法:只关闭客户端TCP的读端,但还可以继续写,这个部分将由函数shutdown完成
  2. 当客户端输入多行数据后,stdio缓冲区存放多行文本数据,此时我们调用fgets却只能读取其中的一行,其余行依然在缓冲区,要等下一次触发标准输入可读时才能读取下一行,不能显示到标准输出。所以,我们需要进行相应的处理,将发送到客户端的缓冲区的数据全部读取到标准输出

6.4 shutdown函数

close函数的两个限制:

  1. close只负责把描述字的引用计数减一,shutdown可以立马激发TCP的连接终止操作
  2. close会关闭数据传送的两个方向:读和写,shutdown可以控制全关闭,和半关闭
#include <sys/socket.h>
int shutdown(int sockfd,int howto);

howto的值:

  1. SHUT_RD:关闭连接读这一半,套接口不再接收数据,且接收缓冲区中的现有数据全被丢弃
  2. SHUT_WR:关闭连接写这一半,对于TCP套接口, 这称为半关闭(half-close),当前留在套接口发送缓冲区的数据发送到服务器,进程不能对该套接口进行任何写操作
  3. SHUT_RDWD:连接的读写都被关闭,相当于连续调用两次shutdown函数,第一次指定SHUT_RD,第二次指定SHUT_WR。

6.5 针对缓冲区的问题,再次修订str_cli发送数据的代码

unix_socket_44

其中:

  1. stdineof是为了标记是否是客户端读取到EOF正常退出,还是服务器异常终止服务进程而发送FIN到客户端
  2. 这里将Readline改为Read,读取单元由一行一行读取改为针对缓冲区大小单元进行读取
  3. 使用shutdown来半关闭可以使得客户端发送缓冲区的数据发送到服务器端,同时,服务器端的发送缓冲区或者是客户端发送给服务器但还在网络中的数据,也可以完整的到达客户端显示。而不是直接关闭客户端进程,导致部分数据丢失

6.6 TCP回射服务器程序

我们这一章节将利用select技术来实现可以处理任意数目客户的单进程程序,而不是像之前为每个客户派生一个子程序。

我们使用一个client整型数组来记录每个客户对应的socket描述符号,然后使用可读描述符集rset:

  • 当客户关闭后(socket描述符为4),client数组中对应项由4设为-1,rset中第4位置为0
  • 当有新客户连接到达时,将client数组中第一个为-1的项记录套接字描述符。同时添加到rset
  • 本服务器可以处理的最大客户数量限制是在FD_SETSIZE(宏)和内核允许本进程打开的最大描述符字数之间的较小数

unix_socket_45

服务器前面监听套接字的监听绑定,初始化部分的代码都一样,区别在于for循环处理新到来的客户端连接请求,这里不是通过fork子进程来处理客户连接而是通过selelct来处理连接。

思路:通过select监听哪些套接字可读,然后循环检测监听套接字和所有客户连接套接字是否可读,如果可读则做对应的处理

unix_socket_46

拒绝服务攻击

一些恶意程序,会只给服务器发送一个字节数据,服务器调用Read函数时,会循环调用read系统调用,因为没有读取到EOF,也没达到指定读取数据量大小,从而导致阻塞。使得该服务器不能服务其它的客户,为了解决这个问题,给出了三个解决方案:

  1. 使用非阻塞I/O
  2. 每个客户由单独的线程提供服务
  3. 对I/O设置超时

6.7 pselect函数

pselect的时长设置的更加精确,并且可以设置信号掩码,可以使得进程禁止递交某些信号,然后再测试这些描述符是否可读,可写或异常。

#include<sys/select.h>
#inclde<signal.h>
#include<time.h>

int pselect(int maxfdp1,fd_set *readset,fd_set *writeset,
  fd_set *exceptset,const struct timespec *timeout,
  const sigset_t *sigmask)//sigmask:信号掩码

struct timespec{
  time_t tv_sec;//秒
  long tv_nsec;//ns纳秒
}

6.8 poll函数

详见高级i/o blog:http://blog.xbblfz.site/2018/04/15/高级i-o/

利用poll实现的服务器程序,主要是配置poll所需要的监听的描述符数组。代码见P159

第七章.套接字选项

有很多方法来获取和设置影响套接口的选项:

  • getsockopt和setsockopt
  • fcntl
  • ioctl
#include <sys/socket.h>

int getsockopt(int sockfd,int level,int optname,void *optval,socklen_t *optlen);//optval和optlen为值结果参数

int setsockopt(int sockfd,int level,int optname,void *optval,socklen_t *optlen);//optval为要设置的选项的新值

套接口选项分为两类:

  1. 标志选项:开启和禁止某个特性的二元(0/1)选项
  2. 值选项:取得和返回我们可以设置或检查的特定值的选项

unix_socket_47 unix_socket_48

7.1 检查选项是否受支持并获取缺省值

根据套接口的选项的需求创建对应类型的套接口,然后调用getsockopt获取选项值并打印出来。

unix_socket_49

unix_socket_50

7.2 套接口状态

下面套接口选项是由TCP已连接套接口从监听套接口继承而来的:SO_DEBUG,SO_DONTROUTE,SO_KEEPALIVE,SO_LINGER,SO_OOBINLINE,SO_RCVBUF,SO_RCVLOWAT,SO_SNDBUF,SO_SNDBUF,SO_SNDLOWAT,TCP_MAXSEG和TCP_NODELAY

7.3 通用套接口选项

下面这些选项和协议无关,所有协议的套接口类型都能使用,但也有些选项只能应用到某些特定类型的套接口中:

7.3.1 SO_BROADCAST

开启或禁止进程发送广播的能力,只有数据报套接口支持广播,数据报套接口会先在内核中测试发送地址是否为广播地址,且广播选项是否设置,然后才能进行数据广播。

7.3.2 SO_DEBUG

支持TCP,开启该选项时,内核将对TCP在该套接口发送和接收的所有分组保留详细的跟踪信息,这些信息保存在某个环形缓冲区中,并可以使用trpt程序进行检查。

7.3.3 SO_DONTROUTE

规定外出发送的分组,屏蔽底层协议的正常路由机制,而是定向到某一个特定端口进行发送。

7.3.4 SO_ERROR

套接口有一个名为so_error的变量,记录错误条件

  • 这是一个只可以获取值而不能设置值的选项 (值选项)
  • getsocketopt获取该选项的值会返回so_error的值,随后so_error会由内核复位为0。
  • 同时当对套接口调用read时如果没有数据返回,会检查so_error的值,如果为非零值,read返回-1,并将so_error复位为0。
  • read如果有数据返回,则返回数据,而不是返回so_error的错误条件
  • 当调用write时,so_error为非0值,返回-1,且errno设置为so_error的值,且so_error被复位

7.3.5 SO_KEEPALIVE

当给一个TCP套接口设置保持存活(keep-alive)选项后,如果2个小时内该套接口一直没有数据传输和接收,则TCP会自动给对段发送一个探测报文段

  • 会出现下面三种情况
    • 对端正常响应
    • 对方进程已经崩溃,或崩溃后重启,返回RST响应,导致本地套接字待处理错误为ECONNRESET,套接口本身也被关闭
    • 对端网络连接不可达:没有响应,每隔75秒重传一个探测分组,一共传8次,若均没有响应,则待处理错误为ETIMEOUT,关闭套接字,若其中有响应是ICMP错误(路由不可达),则返回相应的错误,待处理错误设为EHOSTUNREACH
  • 时间参数是否可改(2小时改为15min)?
    • 因为这些时间参数是面向整个内核维护的,所以修改这个套接口的时间参数,其他所有开启了这个选项的套接字的时间参数也收到影响。

7.3.6 SO_LINGER

该选项指定close对面向连接的协议(TCP和SCTP)如何执行,默认的行为是close函数立即返回,然后将残留在套接口发送缓冲区的数据发送到对端,该选项就是用来修改这个默认的行为

  struct linger{
    int l_onoff;//是否打开
    int l_linger;//标记如何close
  }
  • 当l_onoff为0时,关闭该选项,close执行默认操作
  • 如果l_onoff非0,l_linger为0,当close某个连接时,TCP将夭折该连接,也就是说TCP将丢弃发送缓冲区中的数据,并发送一个RST给对端,没有四次挥手。这主要用来避免TIME_WAIT状态(如果有新的化身,数据会被错误传递)
  • 如果l_onoff为非0且l_linger为非0:close时,套接口将拖延一段指定时间(l_linger的值指定),这段时间内,执行序列是先将发送缓冲区的数据发送到服务器端,然后发送FIN开始四次挥手,会出现以下两种结果:
    • 套接口将所有缓冲区中的数据发送并得到确认后正确返回
    • 设定的拖延时间到返回,返回EWOULDBLOCK错误,还没发送完的 缓冲区数据会被丢弃只要当拖延时间到时,客户端的套接字没有经历完整的四次挥手的过程变成Closed状态,则会报EWOULDBLOCK错误
    • unix_socket_51
    • unix_socket_52
    • unix_socket_53
  • close函数会关闭读写两端,禁止向缓冲区写入(write)数据和读取(read)数据,但发送缓冲区中的数据依然会发送到对端
  • shutdown则可以只关闭一边(读或写),所以还可以向缓冲区添加数据,或读取数据。
    • unix_socket_54

7.3.7 SO_OOBINLINE

带外数据将被留在正常的输入队列中。

7.3.8 SO_RCVBUF和SO_SNDBUF

这两个选项允许我们改变套接字发送和接收缓冲区的缺省大小。对于TCP来说,套接口的接收缓冲区中的可用空间限定了TCP通告对端的接收窗口大小(流量控制),UDP没有流量控制,所以会出现较快的发送端很容易淹没较慢的发送端。

  • 在设置TCP套接口缓冲区大小时,TCP的窗口规模在建立连接时用SYN与对端互换得到
    • 所以SO_RCVBUF选项必须在调用connect之前设置
    • 对于服务器,此选项必须在listen 之前设置
  • 套接口缓冲区的大小总是由新创建的已连接套接口从监听套接口继承而来
  • TCP套接口缓冲区的大小至少必须为相应连接的MSS的四倍(因为快速恢复机制:容纳一个响应+3个重复响应)

7.3.9 SO_RCVLOWAT和SO_SNDLOWAT

接收低潮标记和发送低潮标记,供select函数使用

  • 当接收缓冲区中的 可读数据大于或等于接收低潮标记,触发select “可读”,默认为1字节
  • 当发送缓冲区中的 可用空间大于或等于发送低潮标记,触发select “可写”,默认为2048字节,且 UDP的发送缓冲区的可用空间大小不会变化(因为缓冲区不保留拷贝),所以UDP只要发送缓冲区大小大于或等于低潮标记,则总是”可写”。

7.3.10 SO_RCVTIMEO和SO_SNDTIMEO

这两个选项允许我们给套接字的接收和发送设置一个超时值,参数都是一个指向timeval结构的指针,默认情况下,这两个超时都是禁止的

  • 接收超时影响五个输入函数:read,readv,recv,recvfrom和recvmsg
  • 发送超时影响5个输出函数:write,writev,send,sendto和sendmsg

7.3.11 SO_REUSEADDR和SO_REUSEPORT

  • SO_REUSEADDR有如下四个功能

1.允许启动一个**监听服务器并捆绑其众所周知的端口**,即使以前建立的将该端口用作它们的本地端口的连接仍存在。这个场景通常是这样碰到的: + a) 启动一个监听服务器 + b) 连接请求到达,派生一个子进程来处理这个客户 + c) 监听服务器终止,子进程依然在提供服务 + d) 重启监听服务器绑定到同样的端口(如果不设置SO_REUSEADDR将会绑定失败)

2.允许同一端口启动同一服务器的多个实例,只要每个实例捆绑不同的IP地址即可 + 绑定三个服务器到三个不同地址,通配地址,198.69.10.128 和198.69.10.129,请求会分配到最好匹配的IP地址的服务器上去 + 对于TCP,我们绝不能启动捆绑相同IP和相通端口的服务器,这是完全重复的捆绑 3.允许单个进程捆绑同一个端口到多个不同IP的套接字上去 4.允许完全重复的捆绑,相同IP和相同端口,仅支持UDP套接口(用于多播)。 + 所用规则:如果数据报的目的地址时一个广播地址或多播地址,那就给每个匹配的套接字递送一个该数据报的副本。如果是单播地址,且有多个套接字匹配该数据报,那么该选择由哪个套接字接收它取决于实现

  • SO_REUSEPORT的功能
    • 1.本选项允许完全重复的绑定,不过只有在想要捆绑同一IP地址和端口的每个套接字都指定了套接字选项才行
    • 2.如果被捆绑的IP地址时一个多播地址,那么SO_REUSEADDR和SO_REUSEPOPT被认为是等效的
  • 总结:
    • 1.在所有TCP服务器程序中,在调用bind之前设置SO_REUSEADDR套接字选项
    • 2.当编写一个可在同一时刻在同一主机上运行多次的多播应用时,设置SO_REUSEADDR套接字选项,并将所有参加多播组的地址作为本地IP地址捆绑

7.3.12 SO_TYPE

返回套接口的类型,返回的整数值是一个诸如SOCK_STREAM或SOCK_DGRAM之类的值

7.3.13 SO_USELOOPBACK

仅用于路由域(AF_ROUTE)套接口。默认打开(唯一一个默认打开的SO_XX类型选项),选项开启时,相应套接口将接收在其上发送的任何数据报的拷贝

7.4 IPv4套接口选项

这些套接字选项由IPv4处理,它们的级别为IPPROTO_IP

7.4.1 IP_HDRINCL

7.4.2 IP_OPTIONS

允许我们在IP首部设置IP选项

7.4.3 IP_RECVDSTADDR

导致所收到的UDP数据报的目的IP地址由recvmsg函数作为辅助数据返回

7.4.4 IP_RECVIF

导致所收到的UDP数据报的接收接口索引由recvmsg函数作为辅助数据返回

7.4.5 IP_TOS

允许我们为TCP、UDP或SCTP套接字设置IP首部中的服务类型字段(该字段包含DSCP和ECN子字段)

7.4.6 IP_TTL

设置或获取系统用在从某个给定套接字发送的单播分组上的默认TTL值(多播TTL值使用IP_MULTICAST_TTL套接字选项)

默认值

  1. TCP、UDP:64
  2. 原始套接字:255

给本选项调用getsockopt

  1. 返回系统将用于外出数据报的字段的默认值
  2. 没有办法从接收到的IP数据报中取得该值

7.5 TCP套接口选项

TCP有两个套接字选项, 它们的级别为IPPROTO_TCP

7.5.1 TCP_MAXSEG选项

  • 该选项允许我们获取和设置TCP连接的最大报文段大小(MSS),这个值通常由对端SYN报文段通告的MSS,在连接建立之前(没有收到对端的SYN),则获取的值为默认值。
  • 如果我们使用时间戳选项时,通常实际用于连接中的最大报文段的大小小于该选项的返回值,因为时间戳选项在每个报文段中占用12字节
  • 如果TCP支持路径MTU发现功能,那么他将发送的每个报文段的最大数据量会在存活期内改变,上下调整
  • 一旦连接建立,则TCP报文段大小不能超过对端通告的MSS选项值,但可以小于该选项值。

7.5.2 TCP_NODELAY选项

开启本选项将禁止TCP的Nagle算法,默认情况下,该算法是启动的。

Nagle算法的目的:防止一个连接在任何时刻有多个小分组待确认,减少网络中小分组(小于MSS)的传输,尽量将小分组整合到一个分组中传输,提高有效数据的传输效率。因为TCP总是尽可能地发送最大大小的分组。

Nagle算法的实现:如果某个给定连接上有一个小分组没有得到确认,则不能发送新的小分组到连接中。直到该小分组被确认后,才允许发送新的小分组。

Nagle算法带来的问题:使得用户的输入延迟更加明显

例子:Rlogin或Telnet客户端键入6个字符的串“hello!”,每个字符之间的输入准确相隔250ms,到服务器端的RTT为600ms,而且服务器端接收到数据后会立即发送该字符到客户端回显。

当Nagle算法被禁止时: unix_socket_55

当Nagle算法被启用时: unix_socket_56

Nagle算法常常与另一个TCP算法结合起来使用,ACK停滞算法,该算法使得TCP在接收到数据后不立即发送ACK,而是等待一小段时间(典型为50ms~200ms),然后发送ACK。目的是为了在目的段等待一段时间,期望有数据要发送回源端。这样被延滞的ACK就可以由这些数据捎带,从而节省一个TCP分组。

Nagle算法+ACK停滞算法的缺点:

  1. 对于服务器并不往回发送数据(不用捎带)的客户来说,会明显察觉到延迟
  2. 另一类不适合使用Nagle算法和TCP的ACK停滞算法是发送单个逻辑请求的客户,该请求由一个4字节的请求类型和后跟396字节的请求数据构成,这样396的请求数据必须要等4字节的请求响应才能被发送。明显增加延迟,有三个解决办法:
    • 使用writev而不是两次调用write,writev只会产生一个TCP分节
    • 把前4字节的数据和后396字节的数据拷贝到单个缓冲区,然后对该缓冲区调用一次write
    • 设置TCP_NODELAY套接口选项,继续调用write两次(禁止使用Nagle算法和ACK停滞算法),这个方法最不可取,会有损网络。

第八章.基本UDP套接口编程

下图中给出了典型的UDP客户/服务器程序的函数调用,客户不与服务器建立连接,只管使用sendto函数给服务器发送数据报,服务器不接受连接,只管recvfrom接收数据: unix_socket_57

8.1 recvfrom 和sendto函数

类似于标准的read和write函数,不过需要三个额外参数:

#include<sys/socket.h>

ssize_t recvfrom(int sockfd,void *buff,size_t nbytes,int flags,struct sockaddr *from,socklen_t *addrlen);

ssize_t sendto(int sockfd,const void *buff,size_t nbytes,int flags,const struct sockaddr *to,socklen_t addrlen);

前三个参数sockfd,buff,nbytes和read及write中的参数一致,flags参数暂时我们不关心,姑且认为均设置为0

  • recvfrom: from指针和addrlen指针是值-结果参数,获取接收的数据报的来源地址from,返回值为接收的数据量大小。
  • sendto: to表示数据报要发送的目的地址,addrlen(不为指针)为地址数据的大小,返回值同样是发送数据量的大小

recvfrom和sendto可以用于TCP,但没有理由这么做,因为我们通过getsockname就可以获取到对端地址,没必要使用这两个函数

8.2 UDP回射服务器程序

同TCP中的实现例子一样,这一章将会就同样的客户/服务器使用场景,并结合UDP的网络编程方式实现服务器端/客户端代码:

unix_socket_58

服务器端接收到数据后回显给客户端部分的代码dg_echo:

unix_socket_59

  • 该循环永不终止,因为UDP是一个无连接的协议,没有像TCP有EOF之类的标记
  • 迭代服务器:TCP是并发服务器,因为一个客户就需要维护一条连接,一条连接对应一个套接口,UDP不需要维护连接,所以一个进程,单个套接口可以循环处理来自多个客户的数据
  • UDP有排队发生:UDP套接口都有一个接收缓冲区,缓冲区排队的数据报以FIFO的方式返回给进程

8.3 UDP回射客户端程序

unix_socket_60

客户端数据发送部分代码:

unix_socket_61

这部分代码的问题:当客户端发送的数据报丢失,或服务器端发回的回显数据报丢失时,客户端的代码将一直阻塞于Recvfrom函数调用,防止这样阻塞的典型办法是给recvfrom设置一个超时,但这并不是一个完整的解决办法

unix_socket_62

这部分修改的代码主要是限制发送数据报的服务器端地址和回显数据报的服务器端地址一致,才能在客户端回显。

  • 服务器运行在只有单个IP地址上,这部分代码可以正常工作,然而如果服务器主机是多宿的,则回显可能会失败
  • 当服务器发送数据时,内核将为封装这些应答的IP数据报选择源地址,选为源地址的是外出接口的主IP地址,如果我们客户端是向非主IP地址发送的数据报,则发送回显数据报的IP就会和客户端中的服务器端地址不一致,导致回显失败

解决办法:

  1. 客户端不通过比较IP地址来验证是否来自同一主机,而是通过DNS来验证比较域名
  2. 服务器中为每一个IP创建一个套接口,绑定每个IP地址到每个套接口中去,使得一个套接口传送和接收数据只能对应一个IP地址

8.4 服务器进程未运行

当我们不启动服务器,而启动客户端向服务器端发送数据报,客户端将永远阻塞于它的recvfrom调用。

这个过程中,我们启动tcpdump来观察底层协议输出:

unix_socket_63

  • 我们从第四行看到,主机响应了port unreachable的ICMP消息,但这个错误并不会返回给客户进程,并且客户进程依然永远阻塞在recvfrom系统调用
  • sendto成功返回只表示着将数据报拷贝到输入队列缓冲区
  • 一个基本规则:对于UDP套接口,由它引发的异步错误却并不返回给它,除非它已连接
    • 原因:因为当recvfrom收到一个出错ICMP数据报,如果返回给进程,recvfrom只能返回出错码errno,并不能获取出错来源的宿IP地址和UDP端口号(因为没有连接,这些信息丢失后就无法再次获取)

8.5 UDP的connect函数

在上一节我们提到,除非套接口已经连接否则异步错误是不会返回到UDP套接口的。我们确实可以给UDP套接口调用connect, 然而这样做的结果却和TCP连接相差很多,没有三次握手的过程,所以一端套接口调用connect只会影响到这一端的发送信息,对connect连接的对端没有产生任何影响

实际上,一段调用connect,只是在这一端的套接字的信息中记录对端的IP地址信息和端口,发送数据时,只向该纪录的IP地址和端口发送数据报,同时接收数据时,也只接收来自该IP地址的数据报。

所以我们需要区分:

  • 未连接UDP套接口
  • 已连接套接口

对于已连接UDP套接口,与未连接的套接口相比,主要有三点变化:

  1. 对于已连接的套接口输入数据时,我们不能指定对端IP地址和端口号,所以一般不需要使用sendto(使用时第五个参数一般为空指针),而是使用write和send.套接口会自动发送到connect指定的协议地址
  2. 我们不必使用recvfrom来获得数据报的发送端的IP地址和端口号,而是用read,recv或recvmsg,如果收到不是来自之前connect指定的IP地址和端口号,传输层不会投递到该套接口。
  3. 由已连接UDP套接口引发的异步错误返回给它们所在的进程,而未连接UDP套接口不接受任何异步错误(防止对端IP丢失,已连接不需要担心这个问题)

unix_socket_65

unix_socket_66

总结:

  • 当客户端进程或服务器端进程仅仅在使用自己的UDP套接口与确定的唯一对端进行通信时才可以调用connect,大部分都是UDP客户调用connect,只有小部分网络应用的UDP服务器会与单个客户长时间通信(TFTP),客户端和服务器端会都调用connect,维持长时间固定对端的通信。

给一个UDP套接口多次调用connect

拥有一个已连接UDP套接口的进程再次调用connect是为下列两个目的:

  1. 指定新的IP地址和端口号(设置新地址)
  2. 断开套接口(设置地址为AF_UNSPEC)

性能分析

当应用程序在一个未连接的UDP上调用两次sendto会经历下面六个步骤:

  1. 连接套接口
  2. 输出第一个数据报
  3. 断开套接口连接
  4. 连接套接口
  5. 输入第二个数据报
  6. 断开套接口连接

当套接口连接后,调用两次write操作会经历:

  1. 连接套接口
  2. 输出第一个数据报
  3. 输出第二个数据报

当应用程序知道要给同一宿地址发送多个数据报时,显示连接套接口更有效率。

8.6 使用已连接的UDP套接口修改客户端代码

unix_socket_64

该代码运行在服务器为多宿机的情况下,客户端通过已连接的UDP套接字发送数据报到服务器端,服务器端用外出接口的主IP地址返回数据报到客户端,而该主IP和已连接的UDP套接字connect信息不匹配,所以客户端产生ICMP错误:“udp port 9877 unreachable”。返回给客户端的应用进程

8.7 UDP缺乏流量控制

当UDP缓冲区满的时候,新发送的数据报很容易被丢弃,从而导致发送端淹没接收端

8.8 UDP中的外出接口确定

我们可以创建一个数据报套接字,不绑定bind端口及IP,然后和一个目的端IP地址及端口号进行连接connect,然后通过getsockname来获取连接后系统给该套接字指派的临时端口号外出接口的主IP地址

8.9 利用select实现TCP和UDP复用的服务器程序

思路:创建TCP和UDP套接字绑定在同一端口,然后利用select对两个套接字都设置监听,当任一套接口可读时,检测是哪一个套接口可读,TCP(并发服务器)就调用Fork生成子程序来处理数据,UDP(迭代服务器)直接进行处理然后调用Sendto发送回客户端。

unix_socket_67 unix_socket_68

第十一章.名字与地址转换

11.1 概述

本章将介绍

  • 主机名字到主机数值IP地址的转换:gethostbyname,gethostbyaddr
  • 服务名字与端口号之间的转换:getservbyname,getservbyport
  • 协议无关的转换函数:getaddrinfo,getnameinfo

11.2 域名系统(DNS)

资源记录

DNS中的条目称为资源记录,资源资源的类型有若干个:

  • A : A记录将主机名映射成一个32位的IPv4地址
  • AAAA :称为”四A”记录的AAAA记录把一个主机名映射成一个128位(32x4)的IPv6地址
  • PTR(pointer record) :指针记录,将IP地址映射成主机名
    • 对于IPv4地址,32位地址的4个字节反转顺序,每个字节都转换成各自的十进制ASCLL值,再添上in-addr.arpa
    • 对于IPv6地址,128位地址中的32个4位组反转顺序,转换成相应的十六进制ASCLL值,再添上ip6.arpa
  • MX(mail exchanger) :把左侧给定的主机指定成左侧给定的主机的“邮件交换器”
  • CNAME(canonical name) :规范名字,为常见的服务(ftp,www)指派CNAME记录。如果人们使用这些服务名,而不是真实的主机名,那么相应的服务挪到另一个主机时也不必知道
    • 例子:ftp.unpbook.com的规范名字是linux.unpbook.com

unix_socket_116.png

解析器和名字服务器

每个组织机构往往运行着一个或多个名字服务器,客户和服务器等应用程序通过调用称为解析器的函数库中的函数接触DNS服务器。下图展示了应用程序、解析器和名字服务器的典型关系:

  • 解析器通常包含在一个系统函数库中,在构造应用程序时被链接到应用程序中
  • 另有些系统提供一个由全体应用进程共享的集中式解析器守护进程。
  • 无论哪种情况,应用程序代码使用通常的函数调用来执行解析器中的代码,调用的典型函数是gethostbyname和gethostbyaddr

unix_socket_117.png

DNS的替代方法

  • 静态主机文件(/etc/hosts)
  • 网络信息系统(NIS)
  • 轻权目录访问协议(LDAP)

11.3 gethostbyname函数

# include<netdb.h>

struct hostent *gethostbyname(const char *hostname);//根据主机名查找IPv4地址,成功返回一个指向hostent结构的指针,失败返回空指针,并设置h_errno

//
struct hostent{
  char *h_name;//正式主机名
  char **h_aliases;//别名数组?
  int h_addrtype;//协议类型
  int j_length;//地址长度
  char **h_addr_list;//IPv4地址链表

}

unix_socket_118.png

gethostbyname函数和其他接口不同点在于,当发生错误时,它不设置errno变量,而是将全局整数变量h_errno设置为在中定义的下列常值之一:

  • HOST_NOT_FOUND
  • TRY_AGAIN
  • NO_RECOVERY
  • NO_DATA(等同于NO_ADDRESS):表示指定的名字有效,但是没有A记录

如今多数解析器提供hstrerror函数,该函数以某个h_errno值作为唯一参数,返回一个指向相应错误说明的const char *指针

11.4 gethostbyaddr函数

该函数试图由一个二进制的IP地址找到对应的主机名。

#include<netdb.h>

struct hostent *gethostbyaddr(const char *addr,socklen_t len,int family);

函数返回一个同样指向hostent结构的指针。主机名存放在h_name字段

11.5 getservbyname和getservbyport

像主机一样,服务也通常靠名字来认知,我们在程序代码中通过其名字而不是其端口号来指代一个服务,名字到端口号的映射关系保存在一个文件中(通常是/etc/services)

getservbyname

getservbyname函数用于根据给定名字查找相应服务端口号:

#include <netdb.h>

struct servent *getservbyname(const char *servname,const char *protoname);//服务名,协议(协议如果给定,则必须匹配,不给定协议则匹配默认协议)

 struct servent{
  char *s_name;//服务名
  char **s_aliases;//别名列表
  int s_port;//端口号
  char *s_proto;//使用的协议。

 }

函数返回一个指向servent结构的指针,调用实例:

struct servent *sptr;

sptr = getservbyname("domain","udp");//DNS using UDP
sptr = getservbyname("ftp","tcp");// FTP using TCP
sptr = getservbyname("ftp",NULL);//同上
sptr = getservbyname("ftp","udp");//this call will fail,没有匹配该协议的ftp服务

我们可以查看/etc/services中的文本行:

unix_socket_119.png

getservbyport

#include <netdb.h>

struct servent *getservbyport(int port,const char *protoname);

PS:port必须要使用网络字节序:

struct servent *sptr;

sptr = getservbyport(htons(53),"udp");//DNS using UDP

11.6 我们利用这些函数实现TCP时间获取客户端程序

该程序主要完成的功能如下:将主机名(可以是名字或十进制的IP)和服务名通过第一个参数和第二个参数传入程序,然后建立连接,读取服务器端的数据并显示到标准输入端

unix_socket_120.png

测试:

unix_socket_121.png

11.7 getaddrinfo函数(协议无关)

之前提到的函数gethostbyname及gethostbyaddr这两个函数仅仅支持IPv4,所以我们研究出一个函数getaddrinfo来支持IPv6,该函数可以根据服务名和主机名称来获取主机的IP地址信息。

#include <netdb.h>

int getaddrinfo(const char *hostname,const char * service,const struct addtinfo * hints, struct addrinfo ** result);

struct addrinfo {
  int ai_flags;/* AI_PASSIVE,AI_CANONNAME*/
  int ai_family;//AF_XXX
  int ai_socktype;//SOCK_XXX
  int ai_protocol;//0或者 IPPROTO_XXX for IPv4 and IPv6
  socklen_t ai_addrlen;//length of ai_addr
  char * ai_canonname;//主机的规范名称
  struct sockaddr *ai_addr;//协议地址信息,IP+端口
  struct addrinfo * ai_next;//链表中下一个地址信息结构体
}
  • hostname:可以是一个主机名,也可以是地址串(IPv4的十进制数串或IPv6的十六进制数串)
  • service参数是一个服务名或十进制端口号数串
  • hints:对查找的地址信息的要求暗示,返回的结构要和该暗示信息匹配
  • result:根据主机名和服务名查找返回的IP地址信息链表结果

hints也是一个addrinfo结构,该结构中可以设置的成员包括:

  • ai_flags
  • ai_family
  • ai_socktype
  • ai_protocol

ai_flags成员的可用标志值及其含义如下:

  • AI_PASSIVE:套接口用于被动打开(监听套接口)
  • AI_CANNNAME:告知getaddrinfo函数返回主机的规范名字
  • AI_NUMERICHOST: 防止任何类型的名字到服务映射;service参数必须是一个十进制端口号数串(不能是服务名?)
  • AI_V4MAPPED:如果ai_family成员指定的是AF_INET6,但没有可用的AAAA纪录,就返回与A纪录对应的IPv4映射的IPv6地址
  • AI_ALL:如果AI_V4MAPPED被指定,除了返回AAAA纪录,还返回与A纪录对应的IPv4映射的IPv6地址
  • AI_ADDRCONFIG:按照所在主机的配置选择返回地址类型,也就是只查找与所在主机回馈接口以外的网络接口配置的 IP地址版本一致的地址

导致多个addrinfo返回的情形有以下两个:

  1. 如果与hostname 参数关联的地址有多个,每个IP地址都会返回一次
  2. 如果service参数指定的服务支持多个套接口类型,每个类型的套接口都会返回一次

例子:当请求查找有两个IP地址的某个主机上的domain服务,那么将返回4个addrinfo结构,分别是:

  1. 第一个IP+SOCK_STREAM
  2. 第一个IP+SOCK_DGRAM
  3. 第二个IP+SOCK_STREAM
  4. 第二个IP+SOCK_DGRAM

unix_socket_122.png

使用getaddrinfo的几种常见的用法:

  • 指定hostname和service,这是TCP或UDP客户进程调用getaddrinfo的常规输入。
    • TCP客户在一个循环中针对每个返回的IP地址逐一调用socket和connect,直到有一个连接成功,或所有地址尝试完毕
    • UDP客户由getaddrinfo填入的套接口地址结构用于sendto或connect.如果客户能够判定第一个地址看来不工作,那么可以尝试其他地址
  • 典型的服务器进程只指定service而不指定hostname,同时在hints结构中指定AI_PASSIVE(被动套接字)标志,返回的套接字地址结构中会含有一个通配IP地址(INADDR_ANY或IN6ADDR_ANY_INIT)
    • TCP随后调用socket,bind,listen(被动套接字)
    • UDP服务器将调用socket、bind和recvfrom
  • 服务器程序使用select或poll函数让服务器进程处理多个套接字:服务器将遍历由getaddrinfo返回的整个addrinfo结构链表,并为每个结构创建一个套接字,再使用select或poll

getaddrinfo函数的优点:

  1. 协议无关
  2. 单个函数可以处理主机名和服务名
  3. 所有返回信息都是动态而不是静态分配的

缺点(使用复杂):我们必须先分配一个hints结构,把它清零后填写需要的字段,再调用getaddrinfo,最后遍历一个链表,逐一尝试每个返回地址

11.8 gai_strerro函数

该函数可以根据getaddrinfo返回的错误码返回对应的出错说明:

unix_socket_123.png

11.9 freeaddrinfo函数

由getaddrinfo返回的所有存储空间都是动态获取的,包括addrinfo结构,ai_addr结构和ai_canonname字符串。这些动态获取的存储空间都可以通过该函数释放掉。

#include <netdb.h>

void freeaddrinfo(struct addrinfo *ai);

ai参数应该指向getaddrinfo返回的第一个addrinfo结构。

11.10 host_serv函数

该函数就是对getaddrinfo的封装调用,根据主机名和服务名及协议族、套接字类型查找对应的addrinfo所有地址相关信息。将通过参数family,socktype 来设置getaddrinfo参数中的hints字段。

unix_socket_124.png

11.11 tcp_connect函数

该函数就是针对TCP连接的场景:创建tcp套接口,连接指定的主机名和服务名对应的套接口

  1. 首先getaddrinfo根据服务和主机名获取所有IP地址信息链表addrinfo
  2. 然后循环遍历连接这些IP地址,直到成功连接一个IP 地址。
  3. 最后释放getaddrinfo使用到的动态分配的地址

该函数实现代码如下:

unix_socket_125.png

11.12 tcp_listen函数

该函数针对TCP中需要创建一个套接口绑定到指定的主机名和服务名上进行监听。

  1. 根据主机名(/etc/host)和服务名(/etc/service)得到对应的协议地址addrinfo
  2. 循环遍历协议地址,尝试创建套接字并绑定
  3. 监听绑定成功的套接字
  4. 释放动态分配的内存

unix_socket_126.png

unix_socket_127.png

11.13 udp_client函数和udp_connect函数

利用getaddrinfo来实现udp套接口的创建有一定区别,因为TCP是面向连接的,所以可以将TCP套接口的创建和连接封装在一起,但udp有两种不同的情形:

  • 无连接的udp套接口:udp_client()
  • 有连接的套接口:udp_connect()

udp_client

无连接的udp套接口创建:

#include<udp.h>

int udp_client(const char *hostname,const char *service,struct sockaddr **saptr,socklen_t *len_p);

该函数创建一个未连接的UDP套接口,并返回三项数据,根据提供的主机名和服务名,返回

  • 获取套接字协议地址saptr
  • 地址数据长度len_p
  • 创建的描述符(返回值)

unix_socket_128.png

udp_connect

该函数创建了一个已连接的UDP套接口

#include<udp.h>

int udp_connect(const char *hostname,const char *service);

有了已连接的UDP套接口后,我们就不需要udp_client后面两个参数,因为我们只需要调用write,该套接口就会正确发送到指定的目的端,所以不需要对应的协议地址,然后调用sendto发送。

unix_socket_129.png

11.14 udp_server函数

该函数类似于tcp_listen,用于在服务器端创建一个用于接收数据的udp套接字,但udp套接字不需要listen,只需要创建套接字后bind即可:

#include<unp.h>

int udp_server(const char *hostname,const char *service,socklen_t * lenptr);
//该函数的功能相当于,开启一个套接字用于提供service服务,绑定到主机名hostname(可选,可以通过hostname访问该服务)上

unix_socket_130.png

11.15 getnameinfo函数

该函数和getaddrinfo函数是互补函数,该函数通过套接字协议地址得到对应的主机名称和服务名称。同样为协议无关的函数。

#include <netdb.h>

int get nameinfo(const struct sockaddr *sockaddr, socklen_t addrlen, char *host, socklen_t hostlen, char *serv, socklen_t servlen, int flags);
  • sockaddr,addrlen:为我们提供的套接字协议地址及长度
  • host,hostlen:为查询到并返回的主机名及长度,但当hostlen指定为0时不返回主机名。最长为NI_MAXHOST(1025)
  • serv,servlen:为查询到并返回的服务名及数据长度,但当servlen指定为0时不返回服务名。最长为NI_MAXSERV(32)
  • flags可以指定为如下几个参数:
    • NI_DGRAM:指定查询数据报套接口类型服务的服务名称,因为一个端口在TCP和UDP协议上提供不同的服务。
    • NI_NAMEREQD:若不能根据协议地址进行DNS反向查询则返回错误
    • NI_NOFQDN:只返回FQDN的主机名部分,第一个点号之后的内容被截去,如aix.unpbook.com只返回aix
    • NI_NUMERICHOST:用数值表达格式作为字符串返回IP地址
    • NI_NUMERICSERV:指定用十进制数格式作为字符串返回端口号
    • NI_NUMERICSCOPE:以数值格式作为字符串返回范围标识

11.16 可重入函数

首先我们先介绍可重入函数的概念,一个函数是可重入的说明该函数在第一次调用的执行期间(还没返回)发生该函数的第二次调用,并不会影响到第一次调用的执行结果。通常一个函数是不可重入的话,是因为该函数会使用到一个静态变量(static)的数据,如下:

unix_socket_131.png

发生重入问题的条件是:从它的主控制流和某个信号处理函数中同时调用gethostbyname或gethostbyaddr.当这个调用信号处理函数被调用时,该进程的主控制流被暂停以执行信号处理函数。这就会发生一个函数的重入问题:

unix_socket_132.png

对于inet_XXX函数和可重入问题,注意一下几点:

  • gethostbyname,gethostbyaddr,getservbyname,和getservbyport这四个函数不可重入,因为它们都返回一个指向静态结构的指针。但这些函数有针对于线程的一些可重入版本的实现名字以_r结尾
  • inet_pton和inet_ntop总是可重入的
  • inet_ntoa是不可重入的,也有针对于线程的可重入版本
  • getaddrinfo可重入的前提是由它调用的函数都是可重入的,
  • getnameinfo可重入的前提是由它调用的函数都是可重入的

errno变量也存在类型的重入问题。当设置一个errno到测试该errno之间有一段时间窗口,其它程序修改该errno的值导致错误。

信号处理函数同时考虑这两个问题,解决办法是:

  • 信号函数不调用任何不可重入的函数
  • 预先保存errno的值并事后恢复

标准I/O函数也是不可重入函数。

11.17 可重入函数版本:gethostbyname_r和gethostbyaddr_r函数

  • 不可重入函数版本是使用同一静态变量
  • 可重入函数版本则需要调用者去自行分配存放返回结果的结构内存空间,传入函数填写后返回
  • 最后还要将我们动态分配的内存给释放掉,这样调用步骤就显得很复杂 unix_socket_133.png

11.18 其它网络相关信息

在这一章节中,我们分析了

  • 函数gethostbyname,gethostbyaddr,针对与主机地址到主机名之间的转换
  • 还有getservbyname和getservbyport函数,针对服务名到端口之间的转换。这两类函数都是针对IPv4协议的
  • 为了使的协议无关,我们将这两个函数合并到一个函数中去就是getaddrinfo,对应的函数是getnameinfo。
  • 这些函数都是针对四个方面:主机,网络,协议,和服务的信息进行查找。而这四部分的信息都可以存放在一个文件中,每一个文件都对应着三个访问函数:
    • getXXXent:读取文件中的下一个表项
    • setXXXent :打开文件
    • endXXXent:关闭文件

这四类信息都对应着各自的结构:hostent,netent,protoent,sevent,对应的文件如下表:

unix_socket_134.png

注意:

  • 只有主机和网络信息可通过DNS获取,协议和服务信息总是从相应文件中读取
  • 如果使用DNS查找主机和网络信息,只有键值查找函数才有意义。如果调用gethostent,那么它仅仅读取/etc/hosts文件并避免访问DNS

第十四章. 高级I/O函数

这这一章将从以下几个部分进行讲解:

  • 如何在I/O操作上设置超时(三种方法)
  • 讲解read和write这两个函数的三个变体
    • readv,writev
    • recv,send
    • recvmsg,sendmsg
  • 如何确定套接口接收缓冲区中的数据量
  • 如何在套接口使用C的标准I/O函数库
  • 介绍T/TCP,可以避免三次握手的开销

14.1 如何给I/O操作设置超时

  1. 调用alarm,它到时会发送SIGALARM信号,然后设置信号处理函数中断I/O操作
  2. 利用select来封装I/O操作,将read或要write的套接字描述符用select监听是否可读可写,超时后返回0,如果可读可写再调用对应的I/O操作
  3. 通过对套接字设置SO_RECVTIMEO和SO_SNDTIMEO来设置读写超时。

利用alarm设置超时

利用alarm函数设置超时然后为connect封装一个带有连接超时限制的函数:前三个参数用于调用connect函数,第四个参数用于设置超时限制

unix_socket_69

利用alarm给recvfrom设置超时:

unix_socket_70

使用select为recvfrom设置超时

将具有超时限制的recvfrom封装为一个函数,具有两个参数:一个要监听的描述符,一个为要设置的超时限制。

unix_socket_71 unix_socket_72

利用SO_RECVFROM套接口选项为recvfrom设置超时

unix_socket_73

14.2 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);

前三个参数和read,write相同,最后一个参数flags参数的值或为0,或为下列一个或多个常值的逻辑与: unix_socket_74

  • MSG_DONTROUTE:这个标志告诉内核宿主机在某个直接连接的本地网络上,无需路由表查找,我们随SO_DONTROUTE套接口选项提供了本特性的额外信息。SO_DONTROUTE可以设置该套接口的所有输出操作,也可以,用MSG_DONTROUTE指定单次的输出操作。
  • MSG_DONTWAIT:即使套接口没有打开非阻塞选项,我们也可以通过该标志将单个I/O操作设置为非阻塞的
  • MSG_OOB:对于send,该标志说明要发送带外数据(优先级高的数据),recv则说明即将读入的是带外数据而不是普通数据
  • MSG_PEEK:适用于recv和recvfrom允许我们窥看已可读取的数据,不过系统不丢弃recv和recvfrom窥看走的数据
  • MSG_WAITALL:告知内核不要在尚未读入请求数目的字节之前让一个读操作返回

14.3 readv和writev函数

这两个寒暑类似于read和write,但区别在于,readv和writev可以读取或写入到一个或多个缓冲区。这些操作叫做“分散读,集中写”

#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);

第二个参数都是指向某个iovec结构数组的一个指针,其中iovec 结构在头文件中定义:

struct iovec{
  void *iov_base;//缓冲区起始地址
  size_t iov_len;//长度
};
  • 在这两个函数中俄iovec结构数组中元素的数目存在某种限制,Linux和4.3BSD最多为1024个,HP-UX为2100个
  • writev是一个原子操作,对于UDP协议而言的话,一次writev操作只产生一个数据报
  • 在前面套接字选项章节时,我们提到TCP_NODELAY套接口选项,还可以用writev来避免Nagle算法,因为他会将4字节和数据和396字节的数据集中写到一个数据报中去。

14.4 recvmsg和sendmsg函数

这两个函数是最通用的函数,实际上我们可以把所有read、readv(分散读)、recv(可以设置读取选项)和recvfrom(可以获取来源地址)替换成recvmsg来调用。类似的各种写操作可以换成sendmsg来调用。

#include <sys/sokcet.h>

ssize_t recvmsg(int socketfd,struct msghdr *msg,int flags);

ssize_t sendmsg(int sockfd,struct msghdr *msg,int flags);

这两个函数相当于将其他函数的所需要的参数全部封装到结构体msghdr结构题中去。

struct msghdr{
  //对应recvfrom参数:协议地址+长度
  void *msg_name;//数据来源协议地址
  socklen_t msg_namelen;//协议地址数据长度
  //对应readv参数:*iovec(缓冲区起始地址+长度)+数组元素个数iovcnt
  struct iovec *msg_iov;//iovec缓冲区地址数组
  int msg_iovlen;//数组中的元素个数
  void *msg_control;//辅助数据数组
  socklen_t msg_controllen;//辅助数据数组元素个数
  //对应recv参数:可以设置选项flags.
  int msg_flags;

}
  • 与recvfrom和sendto对应的参数:msg_name,msg_namelen
  • 与readv和writev对应的参数为:msg_iov,msg_iovlen
  • 区分flags参数(值)和msghdr中的msg_flags参数(引用)
    • recvmsg时使用的是后者msg_flags参数,将flags拷贝到msg_flags中去作用来告知内核某个选项被打开,内核还依据recvmsg结果更新msg_flags成员的值
    • sendmsg则忽略msg_flags标志,直接使用flags标志告知内核。

flags和msg_flags作用函数总结如下表:

unix_socket_75.png

下图展示了一个msghdr结构以及它指向的各种信息:我们假设进程即将对一个UDP套接口调用recvmsg

unix_socket_76.png

当我们从192.6.38.100:2000端口到达了一个170字节的UDP数据报要发送,它的目的地址为206.168.112.96,下图展示recvmsg返回时msghdr结构中的所有信息:

unix_socket_77.png

下图汇总了5组I/O函数之间的差异:

unix_socket_78.png

14.5 辅助数据

这一小节介绍辅助数据的用途,结构和一些相关的宏

下图为辅助数据一些常见用途: unix_socket_79.png

辅助数据结构由一个个辅助数据对象构成的数组,每个辅助数据的对象又是以描述该对象的cmsghdr开头:

struct cmsghdr{
  socklen_t cmsg_len;//辅助有效数据的长度
  int cmsg_level;//协议类型
  int cmsg_type;//选项类型
  //后面跟着cmsg_data[]
}

下图为包含两个辅助对象的辅助数据: unix_socket_80.png

下图为通过一个Unix域套接字传递描述符,和传递凭证时cmsghdr的格式: unix_socket_81.png

操作辅助数据的5个宏

由于recvmsg返回的辅助数据可含有任意数目的辅助数据对象,为了对应用程序屏蔽可能出现的填充字节,头文件中定义了5个宏,来简化对辅助数据的操作: unix_socket_82.png

使用这五个宏的操作伪代码实例: unix_socket_83.png

14.6 排队的数据量

我们如果想在不读取数据的前提下知道一个套接口已有多少数据排队等着读取,有三个技术可用于获悉已排队的数据量:

  1. 如果我们获取已排队的数据量的目的是避免读操作阻塞,使用非阻塞I/O即可
  2. 如果我们即想查看数据,又想数据留在接受队列之中,使用MSG_PEEK选项,如果我们不能确定是否有数据可读,结合使用非阻塞套接口使用该标志,也可以组合使用MSG_DONTWAIT和MSG_PEEK
    • 对于字节流套接口,数据没有边界,第一次调用recv并结合MSG_PEEK可获取的数据量,和第二次不使用MSG_PEEK获取的数据量可能发生变化
    • 对于数据报套接口,数据报是有确定边界的,第一次调用recv并结合MSG_PEEK可获取的数据量,和第二次不使用MSG_PEEK获取的数据量是完全一样的。
  3. 利用ioctl的FIONREAD命令,获取套接口接受队列的当前字节数。

14.7 套接口和标准I/O函数

之前所介绍的所有I/O函数都是围绕描述字工作,通常作为Unix内核中的系统调用实现,执行I/O的另一个方法是使用标准I/O库函数。

标准I/O库处理I/O时我们必须考虑到一些细节:自动缓冲输入和输出流的问题。

标准I/O函数库可以用于套接口,不过需要考虑到以下几点:

  1. 通过调用fdopen函数,可以从任何一个描述服创建一个标准的I/O流。类似,调用fileno可以获取一个给定标准I/O流对应的描述符。
  2. TCP和UDP套接口是全双工的,标准I/O流也是也可以是全双工的,只要指定”r+” (读写)打开流即可
    • 我们不能在这样的流上调用一个输出函数(write)后,不调用fflush,fseek,fsetpos或rewind调用就接着调用一个输入函数(read)
    • 同样,我们不能在一个输入函数(read)后面,不调用fseek,fsetops或rewind调用就跟着一个输出函数(write)。
    • 并且fseek,fsetpos和rewind三个函数都调用lseek,而lseek用在套接口上会失败
    • 解决这个问题最好的办法就是打开两个标准I/O流,一个只用于读,一个只用于写

使用标准I/O函数的str_echo函数:

unix_socket_84.png

利用这个函数对三行文本数据实现回射的话,服务器只有在接受到EOF之后才会向服务器回射这三行文本,原因在于缓冲问题:

  • 客户端发送到服务器端的三行数据首先会被标准输入流完全缓冲,缓冲区没满时,不会写入到套接字缓冲区
  • 当输入EOF时,函数退出,C库函数调用标准I/O清理函数,标准的I/O缓冲区才被输入到套接字缓冲区进行回射
  • 随后才进行四次挥手

标准I/O函数库执行以下三类缓冲:

  1. 完全缓冲:只有在缓冲区满,或进程显示调用fflush,或进程调用exit终止自身发生时才进行I/O操作,通常标准I/O缓冲区大小为8192字节
  2. 行缓冲:在碰到换行符,或进程显示调用fflush,或进程调用exit终止自身发生时才进行I/O
  3. 不缓冲:每次调用标准I/O输出函数,都会真正发生I/O

Unix标准I/O函数库规则:

  • 标准错误输出总是不缓冲
  • 标准输入和标准输出,和所有其他I/O流都是完全缓冲,除非是终端设备,则行缓冲

PS:尽量避免在套接口上使用标准I/O函数库

第十五章.Unix域协议

Unix域协议并不是一个实际的协议族,而是在单个主机上执行客户/服务器通信的一种方法,所有API也有所不同。我们可以将Unix域协议视作一种IPC方法

Unix域协议提供两种套接口:

  • 字节流套接口
  • 数据报套接口

使用Unix域协议无非有三个原因:

  1. 快,在同一主机上,使用Unix域协议比TCP套接口快上一倍(因为无需经过协议对数据的封装)
  2. 传递描述符
  3. 传递凭证(用户ID和组ID)

15.1 Unix域套接口地址结构

struct sockaddr_un{
  sa_family_t sun_family;/*AF_LOCAL*/
  char sun_path[104];
};

Unix域协议地址:用于标识客户和服务器的协议地址是普通文件系统中的路径名。这些路径名不是普通Unix文件:除非把它们和Unix域套接字关联起来,否则无法读写这些文件。

  • sun_path:Unix域协议地址,存放在其中的路径名必须以空字符结尾。未指定地址以空字符串作为路径名指示(即sun_path[0]值为0的地址结构),它等价于IPv4的INADDR_ANY常值和IPv6的IN6ADDR_ANY_INIT常值

Unix域套接口的bind调用:

unix_socket_85.png

15.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]返回
  • 新创建的两个套接口不曾命名,也就是说没有涉及到隐式的bind调用
  • 当指定type为SOCK_STREAM时,创建的是一种流管道,它与调用pipe创建的普通管道Unix管道类似,差别在于流管道是全双工的。

15.3 套接口函数

用在Unix域套接口时,套接口函数中存在一些差异和限制。如下:

  1. bind创建的路径名默认访问权限为0777
  2. 与Unix域套接口关联的路径名应该时一个绝对路径名
  3. connect调用中指定的路径名必须是一个当前绑定在某个打开的Unix域套接口上的路径名。绑定过程可能报下面三个错误
    • 该路径名已经存在但不是一个套接口类型的文件
    • 该路径名存在且是一个套接口类型的文件,但没有与之关联的打开的描述符
    • 该路径名存在而且是一个打开的套接口,不过套接口类型不符合
  4. 调用connect连接一个Unix域套接口涉及的权限测试等同于调用open以只写方式访问相应的路径名
  5. Unix域字节流套接口类似于TCP套接口,都为进程提供一个无记录边界的字节流套接口
  6. Unix域数据报套接字类似于UDP套接字:都提供一个保留记录边界的不可靠的数据报服务
  7. 如果对于某个Unix域字节流套接字的connect调用发现这个监听套接字的队列已满,调用就立即返回一个ECONNREFUSED错误(对于TCP,监听套接字会忽略新到达的SYN,而TCP连接发起端将数次发送SYN进行重试
  8. 不同于字节流套接字:在一个未绑定的Unix域套接字上发送数据报不会自动给这个套接字捆绑一个路径名(在一个未绑定的UDP套接字上发送UDP数据报导致给这个套接字捆绑一个临时端口),如果发送端不绑定一个路径名则,目的端无法回射数据给发送端
  9. 类似8,对于某个Unix域数据报套接字的connect调用不会给本套接字捆绑一个路径名

15.4 Unix域字节流客户端/服务器程序

服务器端

服务器做的工作就是创建一个Unix域套接字,然后绑定到一个路径上监听,随后阻塞在accept系统调用上等待连接请求。请求到达后创建子进程提供服务。

unix_socket_86.png

客户端

客户端代码创建一个Unix域套接字,然后connect连接到服务器端绑定的路径名。

unix_socket_87.png

15.5 Unix域数据报客户/服务器程序

服务器端

数据报服务器端,主要是套接口创建的区别:

unix_socket_88.png

客户端

数据报客户端的套接口必须要显式绑定路径名,否则服务器端recvfrom无法获取客户端的路径从而无法回射数据到客户端。

unix_socket_89.png

15.6 描述符传递

当从一个进程到另一个进程传递文件描述符的时候有两种场景:

  1. fork调用之后,子进程会共享父进程所有打开的文件描述符,然后子进程打开待传递的描述符传递给父进程
  2. 不具有亲缘关系的两个进程传递文件描述符,通过创建Unix域套接字,然后sendmsg利用辅助数据传递文件描述符

两个进程中传递描述符的步骤如下:

  1. 创建一个字节流或数据报的Unix域套接口。
    • 如果是场景1(父子进程):父进程调用socketpair创建一个流管道,通过这个流管道sockfd[2]来传递描述符
    • 如果是场景2(不具有亲缘关系):服务器进程创建一个Unix域套接口bind到一个路径名,客户进程也创建一个Unix域套接口,Connect到服务器绑定的路径名上,然后传递描述符
  2. 在步骤一之后两个进程之间用于传递描述符的套接字已经创建后,接下来就是传递描述符的过程,首先我们要传递的描述符可以是任何类型:open,pipe,mkfifo,socket,accept打开的描述符都可以传递。
  3. 发送进程创建一个msghdr结构,其中含有待传递的描述字,作为辅助数据传输,发送一个描述符,会使得该描述符的引用计数加1
  4. 接受进程调用recvmsg接收描述符,接受进程中的描述符和发送进程中的描述符可能不同,这涉及到在接收进程中创建一个新的描述符,而这个新的描述符和发送进程中的旧描述符指向的文件表项是相同的

实例

该例子的功能如下:mycat程序,通过命令行参数取得一个路径名,打开这个文件并拷贝到标准输出。

代码执行细节:

  1. 主函数main获取参数指定路径,调用my_open函数完成文件打开操作
  2. my_open函数实现中,将文件打开的部分交由(Fork)子进程执行
  3. 子进程执行exec交由openfile程序完成对指定路径名文件的打开操作
  4. 在子进程获取文件描述符,并传递给父进程。
  5. 父进程根据文件描述符读取文本数据显示到标准输出。

unix_socket_90.png

父进程要给openfile程序传递三个参数:

  1. 待打开的文件路径名
  2. 打开方式
  3. 流管道中openfile端使用的端口描述符号
  • 并且程序的退出状态来告知父进程文件能否打开,若不能打开要告知发生什么类型的错误
  • 通过exec执行另一个程序来打开文件的优势在于,另一个程序可以是一个setuid到root的程序,能够打开我们通常没有打开权限的文件

mycat程序:

  1. 主函数如下:

unix_socket_91.png

  1. my_open函数的实现:创建子进程,exec openfile程序

unix_socket_92.png

  1. Read_fd函数实现:父进程从流管道sockfd[0]接收文件描述符 unix_socket_93.png

openfile程序:

  1. 主函数如下:

unix_socket_94.png

  1. write_fd函数如下:通过流管道发送文件描述符到父进程。 unix_socket_95.png unix_socket_96.png

15.7 传递凭证

传递凭证的使用场景往往在于客户和服务器通信时,服务器通常需要以一定手段获悉客户的身份,以便验证客户是否具有权限请求相应的服务

传递凭证和传递描述符类似,同时通过sendmsg和recvmsg及辅助数据来传递,区别在于传递凭证时的凭证数据有所区别,且描述辅助数据的cmsghdr也有所区别:

凭证数据如下:

//即通过宏CMSG_DATA(msg_control)得到的数据
struct cmsgcred{
  pid_t cmcred_pid;//发送进程的进程ID
  uid_t cmcred_uid;//发送进程的用户世纪ID
  uid_t cmcred_euid;//发送进程的有效ID
  gid_t cmcred_gid;//发送进程的组ID
  short cmcred_ngroups;//组的数量
  gid_t cmcred_groups[CMGROUP_MAX];//组
}

描述凭证数据的cmsghdr如下:

cmptr->cmsg_level = SOL_SOCKET;
cmptr->cmsg_type = SCM_CREDS;

具体实例不再赘述,见书P370

第十六章. 非阻塞I/O

可能阻塞的套接口分为下面四类:

  1. 输入操作:read,readv,recv,recvfrom,recvmsg共5个函数,但套接口没有数据可读时则一直阻塞
    • TCP:套接口只要来数据就开始读取操作,如果想等到某个固定数目的数据再读,则调用readn或指定MSG_WAITALL
    • UDP:到达一个数据报时可读
    • 对于非阻塞套接口,输入操作如果不能满足,则返回EWOULDBLOCK操作。
  2. 输入操作:write,writev,send,sendto,sendmsg5个函数。如果发送缓冲区没有空间,将被阻塞。UDP不存在真正的发送缓冲区。当使用非阻塞套接口时,如果发送缓冲区根本没有空间,则返回EWOULDBLOCK错误
  3. 接受外来连接:即accept函数,若没有连接到达,则阻塞,非阻塞套接口调用accept,且没有连接,也返回EWOULDBLOCK错误
  4. 发起外出连接:用于TCP的connect函数,该函数涉及到一个三次握手的过程,而且connect函数一直要等到客户收到对自己SYN的ACK为止才返回,这意味着每个connect总是要阻塞至少一个RTT时间。
    • 对于非阻塞TCP套接口调用connect,且连接不能立即建立,那么连接照样发起,但会返回一个EINPROGRESS错误。

16.1 非阻塞读和写:str_cli函数

  • 之前在讨论该函数时,我们曾尝试使用select防止一个客户进程阻塞在单个客户的读或写系统调用上,使得阻塞在读操作上时,但实际上标准输入已经向服务器写入数据同样被阻塞
  • 用select监听描述符,当描述符可读时,调用read读取,然后write写入发送到服务器,但在调用write函数时,也有可能发送缓冲区满而一直阻塞。
  • 所以在这一节,我们将使用非阻塞I/O来实现str_cli函数。

加入非阻塞I/O后会增加对缓冲区管理的复杂度,我们维护两个缓冲区:

  1. to: 标准输入到服务器的数据缓冲区
    • (in)toiptr指针指向标准输入缓冲区可以存放的下一个字节地址
    • (out)tooptr指针指向标准输入缓冲区即将发送到套接口的下一个字节
    • unix_socket_97.png
  2. fr:服务器到标准输出的数据缓冲区
    • (in)friptr指针指向接受套接口可以发送到标准输出缓冲区的第一个字节
    • (out)froptr指针指向标准输出缓冲区可以发送到标准输出的第一个字节
    • unix_socket_98.png
  • str_cli函数实现第一部分代码如下:

unix_socket_99.png

这部分代码主要区别在于:

  1. 非阻塞套接口的设置
  2. 缓冲区的管理,确保有数据可读或有空间可写时,才进行select监听
  • 第二部分代码如下,该部分的工作主要在于执行select中可以进行读套接字的读取操作,然后处理非阻塞读可能出现的异常情况:

unix_socket_100.png

  • 第三部分代码执行select中可以进行写的套接字的写操作,然后处理非阻塞写可能出现的异常情况

unix_socket_101.png

总结以上三部分代码:非阻塞I/O和普通select调用的I/O的区别在于:套接字的非阻塞选项的设置,缓冲区的管理,非阻塞I/O的错误处理

tips :我们可以执行tcpdump命令,来查看一个端口中传送于TCP相关的数据报有哪些:

tcpdump -w tcpd tcp and port 7//指定去捕获值去往或来自端口7的TCP分节,程序输出保存在tcpd文件中

然后我们通过分析这些TCP数据报可以得到如下,这张非阻塞I/O例子的时间线:

unix_socket_102.png

str_cli简单版本

由于非阻塞I/O的实现过于复杂,主要是涉及到缓冲区的管理,所以我们提出另一个解决方案:将客户端的读写任务划分到两个不同的进程中去,异步执行:

unix_socket_103.png

总结之前几个版本的客户端程序的执行速度:

  • 停等版本:354.0s
  • select加阻塞I/O版本:12.3s
  • 非阻塞I/O版本:6.9s
  • fork父子进程版本:8.7s(推荐使用)
  • 线程化版本:8.5s

16.2 非阻塞connect

当在一个非阻塞的TCP套接口上调用connect时,connect将立即返回一个EINPROGRESS错误,但TCP的三次握手动作正常发起。随后我们使用select检测该连接成功或失败的建立条件

非阻塞connect有三个用途:

  1. 我们可以在三次握手期间去执行其他的处理
  2. 我们可以同时建立多个连接
  3. 缩短connect的超时

一些细节需要注意:

  • 尽管套接口是非阻塞的,但如果连接到的服务器在同一个主机上,当我们调用connect时,连接通常都会立刻建立。
  • select检测连接成功或失败的建立条件
    • 连接成功建立时,描述字可写
    • 连接建立失败时,描述字既可读又可写

16.3 非阻塞connect客户端程序

我们实现一个函数connect_nonb()来进行非阻塞connect:

  1. 套接字设置为非阻塞后,调用connect操作
  2. connect会立即返回,在这期间我可以做一些其他的操作
  3. 检查连接是否立即建立(细节1)
  4. 调用select监听套接字
  5. 处理select超时,超时发生后,关闭套接口,阻止三次握手继续建立
  6. select检测到描述符可读或可写,调用getsockopt取得套接口的待处理错误,如果错误值为0,则连接成功建立
  7. 关闭套接字的非阻塞状态。

unix_socket_104.png

如何判断connect发起的三次握手成功建立:

  1. 调用getpeername,如果反悔ENOTCONN错误,则连接失败
  2. 以值为0的长度参数调用read,read 失败则连接失败,read返回0时则连接建立成功
  3. 再调用connect一次,它应该失败,如果错误是EISCONN则连接成功。

被中断的connect

当在一个阻塞的套接口上调用connect,发起三次握手的过程当中connect被中断,connect不能被内核重新启动等待未完成的连接继续完成,返回EINTR。但连接照样发起,我们如何检测连接是否成功建立,同样和非阻塞I/O的调用后处理过程一样,通过select来检测。

16.4 非阻塞connect实例:WEB程序

非阻塞connect的例子出自Netscape的WEB客户程序。客户首先建立一个与某个WEB服务器的HTTP连接,再获取一个主页,该主页上有多个对于其他网页的引用。客户可以使用非阻塞connect同时获取多个网页,将网页获取的串行过程变为并行。提高数据获取速度。

该程序不能使用上面所写的connect_nonb函数,该函数只有在连接已经建立之后才返回。

该程序最多读取20个来自Web服务器的文件,最大并行连接数、服务器主机名、要获取的文件名通过命令行参数提供:

unix_socket_105.png

头文件:

unix_socket_106.png

主程序:

unix_socket_107.png unix_socket_108.png

主程序介绍完成后,介绍主程序中使用到的几个辅助函数:

  1. home_page:获取主页面函数:

unix_socket_109.png

  1. 文件获取的时候使用的发起非阻塞连接的函数:start_connect() unix_socket_110.png

  2. 发起文件请求http get命令的函数: write_get_cmd() unix_socket_111.png

16.5 非阻塞accept

背景:一个繁忙的服务器,它无法在select返回监听套接字的可读条件后马上调用accept。当客户在服务器调用select后,accept之前中止某个连接时(客户向服务器发送RST),源自Berkeley的实现不把这个中止的连接返回给服务器,而其他实现本应该返回ECONNABORTED错误,却返回的是EPROTO错误

问题:在服务器从select返回到调用accept期间,服务器TCP收到来自客户的RST。这个已完成的连接被服务器TCP驱逐出队列(假设其中没有其他已完成的连接),服务器调用accept,但是由于没有任何已经完成的连接,服务器于是阻塞。从而服务器单纯地阻塞在accept调用上,无法处理任何其他就绪的描述符

解决方法:

  1. 当使用select获悉某个监听套接字上何时有已完成连接准备好被accept时,总是把监听套接字设为非阻塞
  2. 在后续的accpet调用中忽略以下错误:
    • EWOULDBLOCK(Berkeley的实现,客户中止连接时)
    • ECONNABORTED(POSIX实现,客户中止连接时)
    • EPROTO(SVR4实现,客户中止连接时)
    • EINTR(如果有信号被捕获)

第二十六章.线程

在传统的Unix模型中,当一个进程需要另一个实体来完成某事时,它就fork一个子进程并让子进程去执行处理。这种范式(模版)一直良好地服务着,但是fork调用却存在一些问题:

  1. fork调用是昂贵的:fork要把父进程的内存映像拷贝到子进程,并在子进程中复制所有描述字。虽然现在流行的写时拷贝技术有一定优化效果,但依然昂贵
  2. fork之后父子进程之间的通信采用进程间通信(IPC)机制。fork之前,父进程向子进程(尚未存在)传递信息很容易,因为子进程会拷贝所有数据空间和文件描述符。但fork后,子进程向父进程传递信息就比较费力了
  • 同一进程内的所有线程共享相同的地址空间,使得线程之间易于共享信息,随之而来带来同步问题。
  • 同一进程内的所有线程除了共享全局变量外还共享:
    • 进程指令
    • 大多数数据
    • 打开的文件
    • 信号处理函数和信号处置
    • 当前工作目录
    • 用户ID和组ID
  • 不过各线程有各自的:
    • 线程ID
    • 寄存器集合,包括程序计数器和栈指针
    • errno
    • 信号掩码
    • 优先级

26.1 基本线程函数

首先我们先介绍5个基本的线程操作函数,随后我们将利用这些函数来把客户/服务器程序重新编写成改用线程取代fork。

pthread_create函数

当一个程序由exec执行启动时,称为初始线程或主线程的单个线程就创建了,其他线程则由pthread_create函数创建:

#include<pthread.h>

int pthread_create(pthread_t *tid,const pthread_attr_t *attr, void *(*func)(void *),void *arg);
  • tid:线程创建后会将线程ID通过该参数返回
  • attr:设置线程的属性:优先级、初始栈大小、是否应该成为一个守护进程等,当该参数为空时说明指定的是默认设置
  • func、arg:创建一个线程时最后指定的参数是由该线程执行的函数及其参数,线程通过调用这个函数开始执行,然后显式终止(pthread_exit),或隐式终止(函数返回)。该函数的参数和返回值都是一个通用指针,可以指向任何内容。
  • pthread_create成功创建时返回0,出错时为某个非零值。与套接字出错情况不同的是,创建线程失败后返回正值而不是负值,也不设置errno。(这五个函数返回值都采取这一方案)

pthread_join函数

类似waitpid等待子进程终止,我们可以通过调用pthread_join等待一个给定线程终止,我们必须要指定线程ID,Pthread没有办法等待任意一个线程。如果status指针非空,来自所等待线程的返回值将存入status指向的位置。

#include<pthread.h>

pthread_t pthread_join(pthread_t tid,void **status);

pthread_self函数

获取线程自身的线程ID,类似于getpid。

#include<pthread.h>

pthread_t pthread_self(void);//获取自身线程ID

pthread_detach函数

一个线程

  • 或是可汇合的(joinable,缺省值,应该就是和其他线程有一定关联关系?),它终止时,它的线程ID和退出状态将留存到另一个线程对它调用pthread_join
  • 或可脱离的(detached,和其他线程没有关联关系,单独提供服务?),当它终止时,所有相关资源都被释放,我们不能等待它们终止

pthread_detach函数把指定的线程转变为脱离状态。

#include<pthread.h>

int pthread_detach(pthread_t tid);

pthread_exit函数

让线程终止

#include <pthread.h>
void pthread_exit(void *status);

但线程退出时,但线程未曾脱离,它的线程ID和退出状态将一直留存到调用进程内的某个其他线程对它调用pthread_join

指针status不能执行局部于调用线程的对象,因为线程终止时,这样的对象也会消失

26.2 使用线程的客户端str_cli函数

该部分实现的代码,就是将客户/服务端代码的fork父子进程版本中*创建子进程来分开处理数据输入和对服务器的数据接收两个任务这个部分用线程**来代替

unix_socket_112.png

代码如下: unix_socket_113.png

26.3 使用线程的服务器端程序

将服务器创建一个子进程服务一个客户的方式改为创建一个线程服务一个客户的方式。 代码如下:

unix_socket_114.png

我们注意到调用Pthread_create函数时的最后一个参数(void *)connfd并不能保证在所有系统上都能工作,要处理这一点需要进行下面的工作。

首先我们分析为什么不能正常工作,因为可能出现下面的情形:

  1. accept返回,主线程将返回值存放到connfd,然后调用pthread_create函数创建线程
  2. Pthread_create创建一个线程,并准备调度doit函数启动执行
  3. 线程启动之前,accept又接收到一个新的连接请求,又创建了一个套接字覆盖到connfd,所以线程启动后connfd发生变化,数据回射到客户端就会出现错误。

为了解决这个问题,我们可以每一次accept返回的套接字描述符存放到一块我们 单独创建(malloc)的内存来存放,然后传递指向该内存的指针,这样该套接字描述符的值就不会被新accept的返回值覆盖

代码如下:

unix_socket_115.png

线程安全函数(以_r结尾)

线程安全条件是:调用者为返回结果预先分配空间,并把指向该空间的指针座位参数传递给函数

26.4 线程特定数据

  • 把一个未线程化的程序转化成使用线程版本时,有时会碰到因其中使用静态变量而引起的一个常见编程错误。
  • 当同一进程中的不同线程几乎同时调用一个使用静态变量的函数就可能发生问题,因为这些函数使用的静态变量无法为不同的线程保持各自的值

这种编程错误有多个解决方法:

  1. 使用线程特定数据,优点:调用顺序无需变动,所有变动体现在库函数中
  2. 将所有参数封装在一个结构体中,包括静态变量,然后动态分配交由函数填写
  3. 不使用静态变量
  • 每个系统支持有限数量的线程特定数据元素,POSIX要求这个限制不小于128
  • 系统为每个进程维护一个我们称之为KEY结构的结构数组,该数组的元素包含一个标志和析构函数指针,且代表一类特定的数据的键值。
  • 然后分散对应到每个线程中Pthread结构中的pkey数组,pkey数组也含有128个元素,每个元素内存放一个指针,指向该类型特定数据在该线程中的实际数据

unix_socket_135.png

unix_socket_136.png

例子:书P77图3.18中的readline函数不可重入,因为要使用read_buf的静态变量,这里我们用线程的特定数据来改进为可重入版本:

  1. 一个进程启动,创建多个线程
  2. 其中一个线程是首个调用readline的线程,该函数首先调用pthread_key_create。在进程维护的Key数组中找到第一个没有被使用的元素,返回对应的键值位,比如,返回1,存放到全局变量r1_key。我们使用pthread_once和变量r1_once来确保pthread_key_create在所有的readline函数调用中只被调用一次
  3. readline调用pthread_getspecific获取本线程在特定键值所代表的特点类型数据的实际数据,返回一个空指针,随后调用malloc分配该线程特定数据的实际数据所需要的内存空间。
  4. 然后调用pthread_setspecific设置该线程特定类型数据的实际数据。
  5. 其它线程(如线程n)调用readline时,也会调用一次pthread_once,由于r1_once的作用,pthread_once不再被调用,所以pthread_key_create也不再被调用,直接使用全局变量r1_key来调用pthread_getspecific获取特定类型数据的实际数据,malloc内存后调用pthread_setspecific设置特定类型数据
  • 当线程终止时,由于线程调用了readline函数,并使用了Key数组中的某一个特定类型的数据,所以线程终止会调用该Key数组中对应键值的析构函数来释放内存

unix_socket_137.png unix_socket_138.png

处理线程特定数据的函数介绍: unix_socket_139.png unix_socket_140.png

使用线程安全函数的编码思路:

  1. 在Key数组中找到第一个没有被使用的键值(并确保只调用一次)
  2. 根据键值获取该线程的实际数据,有则直接使用,没有则调用malloc并设置后使用 unix_socket_141.png unix_socket_142.png

26.5 用线程替代非阻塞I/O

之前在提到非阻塞I/O时,我们曾使用过非阻塞connect,目的是在connect阻塞期间可以使得处理器能做其他工作,提高处理效率,在这一小节,我们可以使用线程来达到这一目的。我们可以创建一个线程用于阻塞connect系统调用,而主程序依然可以进行其他的工作。

线程用于connect的实现代码如下:

unix_socket_143.png unix_socket_144.png unix_socket_145.png unix_socket_146.png

26.6 互斥锁

问题:多个线程访问并修改同一个变量引发同步问题

解决方案:使用一个互斥锁,保护这个共享变量,访问该变量的前提条件是持有该互斥锁。互斥锁是类型为pthread_mutex_t的变量。我们使用以下两个函数为一个互斥锁上锁和解锁。

#include<pthread.h>

int pthread_mutex_lock(pthread_mutex_t *mptr);//上锁
int pthread_mutex_unlock(pthread_mutex_t *mptr);//解锁
  • 如果试图上锁已经被某个线程锁住的一个互斥锁,本线程将被阻塞,直到该互斥锁被解锁为止。
  • 如果某个互斥锁变量是静态分配的,我们就必须把它初始化为常值PTHREAD_MUTEX_INITIALIZER,通过调用pthread_mutex_init函数将它初始化

利用互斥锁的两个线程同时正确访问同一个共享变量的代码版本:

unix_socket_147.png unix_socket_148.png

26.7 条件变量

互斥锁适合防止同时访问某个共享变量,但是我吗需要一种机制使得我吗可以在等待某个条件发生期间使进程阻塞进入睡眠。这就是条件变量:

使用条件变量的例子:

  • 当我们创建多个线程时,Pthread_join函数不能实现等待任意一个进程终止,所以当线程终止时,我们需要递增一个共享变量的计数器ndone
  • 然后主线程不断循环访问测试该ndone来判断是否有线程终止,这种方法叫轮询,非常浪费cpu时间
  • 所以我们需要使用条件变量的技术,当线程终止时,触发条件变量,主线程阻塞在对条件变量的监听上,如果条件变量被触发,则解除阻塞,调用对应的Pthread_join函数。
  • 互斥锁提供互斥机制
  • 条件变量提供信号机制

条件变量时类型为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);//触发一个条件变量

//条件变量应该被初始化为PTHREAD_COND_INITIALIZER;

问题:为什么pthread_cond_wait阻塞坚挺条件变量需要一个互斥量?

答案:因为往往一个条件变量就是等待一个共享变量发生变化后触发,所以我们必须保证在测试该共享变量(一般测试该共享变量是否为0)和阻塞监听该共享变量对应的条件变量的时间窗口中防止还没有开始监听,共享变量就发生了变化,导致信号丢失

pthread_cond_wait是一个以下三个步骤的原子操作,不可中断:

  • 给mptr互斥量解锁后
  • 再阻塞监听
  • 条件变量触发后再上锁

原子操作的原因,同样防止在解锁到阻塞监听期间,发生共享变量的变化,导致信号丢失。

unix_socket_149.png unix_socket_150.png

pthread_cond_signal通常唤醒相应条件变量上的单个线程,有时候一个线程知道自己应该唤醒多个线程,这种情况可以调用pthread_cond_broadcast唤醒等在相应条件变量上的所有线程。 unix_socket_151.png

26.8 Web客户与同时连接

我们重新编写文件拉取程序,避免使用Solaris的thr_join函数,而是结合条件变量及pthread_join函数实现文件并行拉取:

代码见P608。不再具体介绍

第三十章. 客户/服务器程序设计范式

30.1 概述

从开始到现在,我们开发了很多版本类型的服务器程序,通过不断改进和优化提升服务器的处理性能,这一章节将对这些版本的服务器设计范式进行总结:

  • 迭代服务器(第1章):循环处理客户请求,在一个请求处理完之前不能处理另外的请求
  • 并发服务器(第5章):fork派生子进程处理客户请求
  • select服务器(第6章):用select处理多个连接的socket监听,单个进程完成
  • 线程服务器(第26章):用创建线程替代子进程来处理客户请求

这一章还提出了两个新的服务器设计范式:

  • 预先派生子进程:让服务器在启动阶段调用fork创建一个子进程池,每个客户请求由当前可用子进程池中的某个(闲置)子进程处理
  • 预先派生线程:同时,创建线程池。

30.2 TCP客户端程序设计范式

  • 基本的TCP客户程序(第5章),存在两个问题:
    • 进程在被阻塞等待用户输入期间,看不到对端关闭连接等网络事件
    • 以停-等模式运作,批处理效率低
  • select版本的迭代客户程序(第6章)
    • 优点:可以在等待用户输入期间,得到网络套接字事件的通知
    • 缺点:不能正确处理批量输入的问题
    • 解决:通过shutdown解决
  • 非阻塞I/O(第16章)
  • fork子进程分别处理等待用户输入和等待网络事件通知(第16章)
  • 使用线程取代两个子进程(第26章)

30.3 TCP测试用的客户程序

用于测试的客户程序,将想要建立的连接数,要服务器端返回的数据量都作为参数传入,客户程序创建指定数目的连接向客户端写请求。 unix_socket_152.png

我们将用这个程序对以下9个服务器设计范式进行测试:

  1. TCP迭代服务器程序
  2. TCP并发服务器程序,每个客户一个子进程
  3. TCP预先派生子进程服务器程序,accept无上锁保护
  4. TCP预先派生子进程服务器程序,accpet使用文件上锁保护
  5. TCP预先派生子进程服务器程序,accpet使用线程上锁保护
  6. TCP预先派生子进程服务器程序,传递描述符
  7. TCP并发服务器程序,每个客户一个线程
  8. TCP预先创建线程服务器程序,每个线程各自accpet
  9. TCP预先创建线程服务器程序,主线程统一accpet

测试时调用的客户端命令如下:

# client:程序名
# 206.62.226.36:服务器IP
# 8888:服务器端口号
# 5:由客户fork的子进程数
# 500:每个子进程发送给服务器的请求数
# 4000:每个请求要求服务器返送的数据字节数
% client 206.62.226.36 8888 5 500 4000

这将建立2500个服务器的TCP连接(5个子进程*每个子进程500个请求),在每个连接上,客户向服务器发送5个字节数(“4000\n”),服务器向客户返回4000字节数据。我们在两个不同的主机上针对同一个服务器执行本客户程序,于是总共提供5000个TCP连接,而且任意时刻服务器端最多同时存在10个连接

30.3 TCP并发服务器程序,每个客户一个子进程

对应上述9个范式中的范式2:TCP并发服务器程序,每个客户一个子进程,该范式所需CPU时间最多

并发服务器的问题在于为每个客户现场fork一个子进程比较耗费CPU时间。以后若干节将讲解各种技术就避免并发服务器为每个客户现场fork的做法。

unix_socket_153.png

服务器对来自客户端的数据处理函数:web_child()

unix_socket_154.png

30.4 TCP预先派生子进程服务器程序,accept无上锁保护

这一节将介绍一种新的服务器范式,服务器预先派生多个子进程作为进程池来服务客户:换一种理解就是预先创建多个子进程然后进行迭代服务

unix_socket_155.png

  • 优点:就是可以避免父进程在动态处理客户请求过程中需要不断执行fork的开销
  • 缺点:父进程必须在服务器启动阶段猜测需要预先派生多少子进程

服务器主函数实现:

unix_socket_156.png

进程池创建子进程代码:

unix_socket_157.png

unix_socket_158.png

4.4BSD上的实现

多个进程同时在同一个监听套接字上调用accept这样的情形在4.4bsd内核中的实现方式:

unix_socket_159.png

  • 父进程创建子进程过程中调用fork,所有描述符也会被复制,但这些子进程中的描述符都指向内核中同一个file结构
  • 该file结构中有一个引用计数,当有N个子进程时,引用计数为N+1
  • 多个进程对同一个套接字调用accept被内核投入睡眠时,当第一个客户请求到达,所有N个子进程均被唤醒,使得它们在同一个等待通道,其中只有最先运行的子进程获得该客户连接,其余N-1个进程再次投入睡眠
  • 惊群问题:当只有一个进程获得连接但却需要唤醒所有的进程,导致性能损耗的现象
  • 内核调度算法会将所有客户连接均匀的分布到进程池中的每个进程中去。

select冲突

如果有多个进程阻塞在引用同一个实体的描述字上,那么最好直接阻塞在诸如accept之类的函数中而不是select之中,因为select冲突会引入额外开销。代码如下:

unix_socket_160.png

30.5 TCP预先派生子进程服务器程序,accept使用文件上锁保护

前一小节是多个子进程同时对同一个套接字进行accept系统调用,这部分将对accept调用进行加锁的操作,保证每次只有一个子进程阻塞在accept调用之中。

我们这部分将使用fcntl来进行加锁和解锁。代码我们只给出改动的部分(accept部分):

  my_lock_init("/tmp/lock.XXXXXX");//创建一个临时文件,设置为文件锁
  for(i=0;i<nchildren;i++)
    pids[i] = child_make(i,listenfd,addrlen);

  for(;;)
    clilen = addrlen;
    my_lock_wait();// 加锁操作
    connfd = Aceept(listenfd,cliaddr,&clilen);
    my_lock_release();//解锁操作
    web_child(connfd);//处理客户数据
    Close(connfd);

POSIX文件上锁功能:

unix_socket_161.png

上锁操作和解锁操作的实现:

unix_socket_162.png

虽然对accept进行加锁保护,但还是会存在惊群问题。

30.6 TCP预先派生子进程服务器程序,accept使用线程上锁保护

和文件上锁唯一的区别在于锁的初始化以及上锁和解锁的操作实现,改为线程上锁,因为文件上锁涉及到文件操作可能比较耗时。

线程锁初始化操作:

unix_socket_163.png

线程锁上锁解锁操作: unix_socket_164.png

30.7 TCP预先派生子进程服务器程序,传递描述字

  • 之前介绍的进程池服务器范式都是通过创建子进程然后均调用accept来接受请求连接
  • 这一小节将使用父进程来调用accept,然后将accept之后用于和客户端通信的套接字传递给子进程进行服务,这样可以避免子进程调用accept而需要提供上锁保护等操作。
  • 缺点在于父进程需要跟踪子进程的忙闲状态,好将描述符传递给空闲的子进程进行服务。

unix_socket_165.png

所以在父进程中创建子进程的过程中还需要创建用于父子进程之间用于传递套接字描述符的流管道unix_socket_166.png

服务器代码main实现代码(创建监听套接字,进程池,和子进程通信的流管道,select监听套接字和流管道,并跟踪子进程的信息): unix_socket_167.png unix_socket_168.png

子进程处理客户数据代码(循环阻塞读取流管道传递过来的套接字描述符,接收到后通过该描述符进行通信,通信完成后,返回数据通知父进程空闲): unix_socket_169.png

30.8 TCP并发服务器程序,每个客户一个线程

父进程循环阻塞在accept系统调用上,每接收一个连接请求就创建一个线程来对客户提供服务。

unix_socket_170.png

30.9 TCP预先创建线程服务器程序,每个线程各自accept

父进程的主要任务就是创建监听套接字并listen,然后将线程池中指定数目的线程创建出来即可,其它的工作均交给线程去完成。

首先我们看一下pthread07.h头文件定义的用于维护每个线程若干信息的Thread结构:

unix_socket_171.png

父进程中main 函数实现: unix_socket_172.png unix_socket_173.png

线程的创建及工作: unix_socket_174.png

30.9 TCP预先创建线程服务器程序,主线程统一accept

这个类似于之前父进程负责accept连接请求,然后将描述服传递给子进程,因为所有线程和所有描述字都在同一个进程之内,所以我们直接使用描述字即可,不需要进行描述字的传递。

同样我们需要维护所有的线程信息:

unix_socket_175.png

主线程main函数:

  • 等待连接,将连接描述符存放到clifd数组(clifd需要加锁)
  • 检测iput==iget,如果相等说明,数组太小,线程处理速度不够
  • 不相等,描述符存入数组,条件变量触发线程开始处理连接

unix_socket_176.png

线程池中的线程创建及工作处理:

  • 如果iget==iput说明没有需要处理的连接,对条件变量进行监听,等待新的连接到达后触发
  • 监听条件变量之前要对监听的共享变量clifd加锁
  • 根据索引iget获取要处理的描述符,然后对连接提供数据处理服务,并增加该线程已经处理的连接计数。

unix_socket_177.png

30.10 总结

  1. 当系统负载较轻时,一个客户用一个子进程处理的服务器范式足够了
  2. 相比于传统的每个客户fork一次进程的范式,预先创建进程池或线程池的范式可以将进程控制CPU时间降低10倍以上
  3. 某些实现允许多个进程或线程同时阻塞在一个accept调用中,另一些实现却需要对accept进行上锁保护
  4. 让子进程和线程单独调用 accept比让父进程或主线程统一调用accept并把文件描述符传递给子进程或线程来的更简单迅速
  5. 由于select冲突的原因,让所有子进程或线程阻塞在同一个accept调用中比让它们阻塞在同一个select更为可取
  6. 使用线程远快于进程。