Socket编程

1. 绪论

1.1 OSI模型

Alt text
Socket API位于上三层和下四层之间。
理由:

  1. 上三层主要处理应用逻辑,对通信细节了解不多;下三层主要对应用了解不多,但是处理所有的通信细节。(发送数据,等待确认,给无序到达的数据排序,计算并验证校验和)
  2. 上三层构成用户进程,底下四层作为操作系统内核一部分提供。

所以在这个地方设立API

TCP和UDP之间的间隙表明网络应用绕过传输层直接使用IPV4和v6是有可能的,这是所谓的raw socket.

2. TCP、UDP、SCTP

2.1 协议介绍

TCP

  1. 提供Server和Client之间的连接
  2. 提供可靠性:要求收到ACK,失败重传,多次才放弃。
  3. 含有估算RTT的算法,持续估算一个给定连接的RTT
  4. 通过给每个字节关联一个序列号对数据排序。
  5. 提供流量控制:告知对端一次能够接收到多少数据。
  6. 全双工通信

UDP

  1. 缺乏可靠性:没有重传,确认,序列号,RTT估算。
  2. 有一个数据包长度。(TCP面向流没有记录边界)
  3. 无连接
  4. 可以全双工
  5. 没有流量控制

SCTP

Alt text

2.2 TCP详细说明

三次握手

建立一个TCP连接会发生下面

  1. Server准备好接受连接(被动打开),socket,bind,listen完成。
  2. Client通过调用connect发起主动打开,这导致客户TCP发送一个SYN(同步)分节,通常SYN分节不携带数据,其所在的IP数据报只有一个IP首部,一个TCP首部和可能的TCP选项。(SYN J)
  3. Server必须ACK Client的SYN,同时自己也得发送一个SYN分节(Server在同一连接发送的数据初始序列号)。(ACK J+1和SYN K)
  4. Client必须确认Server的SYN (ACK K+1)
    这种交换至少需要3个分组,称为三路握手。
    Alt text

    TCP选项

    一个SYN可以含有多个TCP选项
  • MSS(最大分节大小): 发送端使用接收端的MSS值作为发送的最大分节大小。
  • 窗口规模: TCP连接任何一端都能通告对端的最大窗口大小是65535。
  • 时间戳选项: 高速连接必要,无需关心。

TCP连接终止

终止一个连接发生:

  1. 某个应用先调用close,主动关闭,该端的TCP发送一个FIN分节,表示数据发送完毕。
  2. 接受到这个FIN的对端执行被动关闭,这个FIN由TCP确认。接受作为一个文件结束符(EOF)传递给接收端应用程序(放在任何其他数据后)。
  3. 一段时间后接受到这个文件结束符的应用进程将调用close关闭它的Socket。这导致它的TCP也发送一个FIN。
  4. 接收最后这个FIN的原发送端TCP(即执行主动关闭那一端)确认这个FIN。

Alt text

每个方向都需要一个FIN和一个ACK,因此通常为4个分节。但是某些情况1的FIN随数据一起发送,2和3的分节都出自被动关闭的一段,可能被合并为一个分节。
并不是所有的情况都是客户主动关闭,在(HTTP/1.0)服务器执行主动关闭。

TCP状态转换图

Alt text
图中展示了TCP的11种状态,可使用netstat显示,它是调试C/S应用时很有用的工具。

观察分组

Alt text

图中的Client通告一个536的MSS(表明Client只实现了最小重组缓冲区),Server通告一个1460的MSS(以太网IPv4的典型值)。不同方向上的MSS不同不成问题。

  1. Server对Client的SYN的ACK是伴随应答发送的,这种做法称为捎带(通常在应答小于200ms)。如果Server用时更长,则是先ACK,后应答。
  2. 执行主动关闭的一端,进入TIME_WAIT状态。
  3. 如果只是发送单个分节请求和接受一个单分节的应答,那么使用TCP有8个分节开销。如果改用UDP只需要交换两个分组,一个承载请求,一个承载应答。
  4. 但是会丧失可靠性,要通过UDP应用层保证,包括拥塞控制也由UDP处理。

TIME_WAIT状态

主动关闭一端停留在这个状态持续时间是最长分节生命期(MSL)的两倍,有时称为2MSL。任何TCP实现都必须为MSL选择一个值。(具有最大跳限255的分组在网络中存在时间不可能超过MSL)。
这个状态存在的理由

  1. 可靠地实现TCP的全双工连接的终止
  2. 允许老的重复分节在网络中消失
  • 第一原因是因为主动关闭的一端最后的ACK可能丢失,TIME_WAIT保留状态信息,重新发送ACK。
  • 第二原因是防止老的连接的重复分组称为新的连接的化身,因为TIME_WAIT是MSL的两倍,这样就足够让某个方向上的分组最多存活MSL秒被丢弃,另一个方向上的应答最多存活MSL秒也被丢弃。

端口号和套接字对

端口号
任何时候,多个进程可能同时使用TCP、UDP和SCTP这3种传输层协议的一种。这3种协议都使用16位整数的端口号区分进程。
例如,支持FTP的任何TCP实现都把21分配给FTP Server。TFTP的Server UDP实现端口号69。
而客户使用的是短期存活的临时端口,由传输层协议自动赋予用户,不关心端口,只要唯一。
端口号划分如下

  • 0-1023 众所周知端口,保留端口,必须由超级用户使用
  • 1024-49151 已登记的端口
  • 49152-65535 临时端口
    套接字对
    一个TCP连接的SocketPar是一个定义该连接的四元组(本地ip,本地端口,外地ip,外地端口)。套接字对唯一标识一个网络上的每个TCP连接。

TCP端口号和并发服务器

并发Server使用循环来派生子进程处理每个新的连接。
Server被动打开
Alt text
使用( :21, : *)指出服务器的套接字对,称为监听套接字。
现在在IP:206.168.112.219启动第一个客户,对12.106.32.254执行主动打开。
Alt text
当Server接收这个客户的连接,它fork一个自身的副本,让子进程处理客户的请求。
Alt text
下一步假设在客户主机另有一个客户请求Server
Alt text

不同点:客户端{-:本地端口2,-:-},服务器{-:-,-:外地端口2}。这时候使用新的1501端口。
TCP无法仅仅查看目的端口号来分离外来的分节到不同的端点。必须查看4个元素才能确定哪个端点接收某个到来的分节。

缓冲区大小和限制

协议 数据包大小 备注 要求最小MTU 分片 最小重组区大小
IPV4 65535B 包含v4首部 68B(20B首部+40B选项+8B片段偏移) 主机和转发的路由器 576B
IPV6 65575B 包含40B的v6首部,净负荷65535B 1280B 68B(20B首部+40B选项+8B片段偏移) 主机 1500B
  • MTU:以太网1500B,两个主机相反方向上MTU不一致。
  • 当IP数据包>链路MTU时分片,到达目的的时重组。
  • 对于IPV4 当df=1,不分片。
  • 以太网中ipv4的MSS=1460(最大65535),ipv6的MSS=1440。
    Alt text
    Alt text

TCP输出

某个应用进程写数据到TCP socket如下。
当write被调用时
Alt text
Alt text

对端TCP必须确认收到的数据,伴随对端的ACK到达,才能丢弃缓冲区已经确认的数据。
本端TCP以MSS大小及更小的块发送到IP,目的是避免分片。如果链路层输出队列满,错误向上传递到TCP然后重传。

####UDP输出
某个应用进程写数据到UDP socket如下。
Alt text
Alt text

socket缓冲区并不存在,因为UDP不可靠。

####常见Internet协议
Alt text
Alt text

3. 套接字编程简介

3.1 概述

从套接字地址开始,它可以在两个方向上传递:进程到内核,内核到进程。内核到进程方向传递是value->result。
地址转换函数在地址的文本表达和它们存放在套接字地址结构中的二进制值之间转换。ipv4大多数情况使用inet_addrinet_ntoa两个函数。新函数inet_ptoninet_ntop同样适用v4和v6。
为了消除这个函数的协议相关性,使用sock_开头的函数。

3.2 套接字地址结构

IPv4套接字结构

1
2
3
4
5
6
7
8
9
10
11
12
13
<netinet/in.h>
struct in_addr{
in_addr_t s_addr; /* 32位 IPV4 地址 */
}; /* 网络字节序 */
struct sockaddr_in{
uint8_t sin_len ;/* 结构的长度(16) */
sa_family_t sin_family; /* AF_INET */
in_port_t sin_port; /* 16位TCP和UDP端口号 */
/* 网络字节序 */
struct in_addr sin_addr; /* 32位 IPV4 地址 */
/* 网络字节序 */
char sin_zero[8]; /* 未使用 */
};

Alt text

  • sa_family_t:支持长度字段,uint8_t;不支持长度uint16_t。
  • in_addr_t: uint32_t
  • in_port_t: uint16_t
    32位ip地址有两种访问方法:in_addr结构和int_addr_t(uint32_t)。
    套接字地址结构仅在给定主机使用,不在主机之间传递

通用套接字地址结构

1
2
3
4
5
6
7
8
9
10
11
<sys/socket.h>
struct sockaddr{
uint8_t sa_len;
sa_family_t sa_family; /* 地址族:AF_xxx value*/
char sa_data[14]; /* 协议相关的地址 */
};

int bind(int, struct sockaddr*, socklen_t); /* 这要求对套接字地址的指针进行强制转换 */

struct sockaddr_in serv; /* ipv4 地址结构 */
bind(sockfd,(struct sockaddr*)serv,sizeof(serv));

IPv6套接字结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<netinet/in.h>
struct in6_addr{
uint8_t s6_addr[16]; /* 128位 IPV6 地址 */
};
/* 网络字节序 */
#define SIN6_LEN /* 编译期测试需要 */
struct sockaddr_in6 {
uint8_t sin6_len ;/* 结构的长度(28) */
sa_family_t sin6_family; /* AF_INET6 */
in_port_t sin6_port; /* 传输层端口号 */
/* 网络字节序 */
uint32_t sin6_flowinfo; /*流信息,未定义*/
struct in6_addrs sin6_addr; /* 128位 IPV6 地址 */
/* 网络字节序 */
uint32_t sin6_scope_id; /* 接口集合 */
};
  • 如果支持字段长度,SIN6_LEN常值必须定义
  • 结构中字段的先后顺序进行过编排,64位对齐
  • sin6_flowinfo分成两个字段
    • 低序20位flow label
    • 高序12位保留
  • sin6_scope_id标识范围

套接字地址比较

Alt text
Alt text
前两种固定长度,后两种可变长度

3.3 值-结果参数

当往一个socket函数传递套接字地址结构时,使用引用的方式传递,传递方式取决于方向

  1. 从进程–>内核:bindconnectsendto
1
2
struct sockaddr_in serv;
connect(sockfd,(SA*)&serv,sizeof(serv));

Alt text

  1. 从内核–>进程:acceptrecvfromgetsocknamegetpeername
    1
    2
    3
    4
    5
    struct sockaddr_un cli;   /*unix domain*/
    socklen_t len;

    len = sizeof(cli); /*长度是一个value*/
    getpeername(unixfd,(SA*) &cli, &len); /*传递两个指针*/

这里把整数改成指针的原因是:当函数被调用时,结构大小是一个值(value),它告诉内核该结构大大小,这样内核在写该结构时不至于越界;当函数返回时,结构大小是一个结果(result),它告诉进程内核在该结构中究竟存储了多少信息。这种类型的参数称为value-result参数
Alt text
Alt text

3.4 字节排序函数

小端(little-endian)字节序:将低序字节存储在起始地址。
大端(big-endian)字节序:将高序字节存储在起始地址。
Alt text
因为每个TCP分节都有16位的端口和32位的ipv4地址,发送协议栈和接收协议栈必须就这些多字节字段各个字节的传送顺序达成一致。网际协议使用大端字节序传送多字节整数。
主机字节序和网络字节序的转换使用如下

1
2
3
4
5
6
#include<netinet/in.h>
uint16_t htons(uint16_t host16bitvalue)
uint32_t htonl(uint32_t hot32bitvalue)

uint16_t ntohs(uint16_t net16bitvalue)
uint32_t ntohl(uint32_t net32bitvalue)

3.5 字节操纵函数

名字以b开头的函数:用于所有支持套接字函数的OS
名字以mem开头的函数:支持ANSI C函数库的所有OS

1
2
3
4
5
6
7
8
#include<strings.h>
void bzero(void *dest,size_t nbytes); /* 把目标字符串中指定数目的字节置为0 */
void bcopy(const void *src,void *dest,size_t nbytes);/* 将指定数目的字节从源字节串copy到目标字节串*/
int bcmp(const void *ptr1,const void *ptr2,size_t nbytes);/*比较任意两个字节串,如相等返回0 */

void *memset(void *dest, int c, size_t len); /* 把目标字符串中指定数目的字节置为c */
void *memcpy(void *dest, const void *src, size_t nbytes);/* 将指定数目的字节从源字节串copy到目标字节串*/
int memcmp(const void *ptr1, const void *ptr2, size_t nbytes);/*比较任意两个字节串,如相等返回0,否则返回两个字符串第一个字节比大小的结果 */

3.6 地址转换函数

inet_aton、 inet_addr和inet_ntoa函数

适用于v4
shell输入的ip(ascll字符串) <--> 套接字地址中的ip(网络字节序的二进制值)

1
2
3
4
#include<arpa/inet.h>
int inet_aton(const char *strptr, struct in_addr *addrptr);/*返回:若字符串有效则为1,否则为0 */
in_addr_t inet_addr(const char *strptr);/* 返回:若字符串有效则为32位二进制网络字节序的IPv4地址,否则INADDR_NONE*/(不能处理255.255.255.255)
char *inet_ntoa(struct in_addr inaddr);/* 返回:指向一个点分十进制的指针*/

inet_pton和inet_ntop函数

适用于v4和v6,函数的p为presentatin,n为numeric。

1
2
3
#include <arpa/inet.h>
int inet_pton(int family,char* strptr, void *addrptr);/* 返回:若成功则为1,若输入不是有效的表达格式则为0,若出错则为-1 */
const char* inet_ntop(int family,void *addrptr,char *strptr, size_t len);/* 返回:若成功则为指向结果的指针,若出错则为NULL */

Alt text

sock_ntop和相关函数
书中使用的和协议无关的函数

readn、written和readline函数

字节流套接字(如TCP)的read和write不同于通常的文件io。输入或输出的字节数可能比请求的数量少,然而这不是出错的状态。
原因在于内核中的缓冲区可能已经满了,此时会再次调用read或write来输出剩余的字节。
为了不让实现返回不足的字节计数值,使用written来取代write。

1
2
3
4
#include "unp.h"
ssize_t readn(int filedes, void *buff, size_t nbytes);
ssize_t written(int filedes, const void *size buff, size_t nbytes);
ssize_t readline(int filedes, void *buff, size_t maxlen); /* 均返回读或者写的字节数,若出错则为-1 */

readline 低效,每读一个字节就调用一次read函数。需要自己实现快速地版本(实现一套缓冲机制)。
基于文本行的网络协议:SMTP、HTTP、FTP的连接控制协议和finger。

4. 基本TCP套接字编程

4.1 概述

Alt text

4.2 socket函数

1
2
#include<sys/socket.h>
int socket(int family,int type,int protocol); /*返回套接字描述符*/

Alt text
Alt text
Alt text
Alt text

对比AF_XXX和PF_XXX,
AF_xxx表示地址族,PF_xxx表示协议族,书中只使用地址族。

4.3 connect函数

1
2
#include<sys/socket.h>
int connect(int sockfd,const struct sockaddr *servaddr, socklen_t addrlen); /*成功返回0,否则-1*/
  1. 调用connect前不必bind,内核会确定地址和端口
  2. TCPsocket会激发三次握手过程,且仅在连接成功或者出错才返回。
  3. 出错有这几种可能
    • TCP client 没有收到ACK
    • TCP client 收到RST(复位),表示该主机在指定端口没有进程等待。(硬错误)
    • RST产生条件:目的地为某端口的SYN到达然而没服务器等待,TCP想取消一个已有连接,TCP收到一个根本不存在的连接上的分节。
    • 如果在某个路由器上出现ICMP会引发软错误。
1
closed-->syn_sent-->established

4.4 bind函数

bind函数把一个本地协议地址赋予一个套接字。

1
2
#include<sys/socket.h>
int bind(int sockfd,const struct sockaddr *myaddr, socklen_t addrlen); /*成功返回0,否则-1*/

  1. 服务器启动时捆绑接口,一般指定端口(RPC除外,会临时分配端口)。
  2. 对于TCPclient,就为该套接字上发送的IP数据报指派了源IP地址。
  3. 对于TCPserver,就限定该socket只接收那些目的地为这个ip地址的客户连接。
    根据结果可以如下设定sin_addrsin_port或者sin6_addrsin6_port
    Alt text

    通配地址有INADDR_ANY=0指定
    捆绑非通配地址用于在子网内让IP层接受所有目的地为任何一个别名地址的外来数据报。

    1
    2
    3
    4
    5
    struct sockaddr_in servaddr;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY); /* for ipv4 */

    struct sockaddr_in serv;
    serv.sin6_addr = in6addr_any; /* 系统预先分配了in6addr_any的extern声明 */

4.5 listen函数

listen函数仅在TCP server使用,它只做两件事。

  1. 当socket函数创建套接字时,被假设为主动套接字。listen函数把它转化为监听套接字(被动套接字),指示内核接受请求。CLOSED-->LISTEN
  2. 本函数的第二个参数规定了内核应该为相应套接字排队的最大连接个数。
1
2
#include<sys/socket.h>
int listen(int sockfd,int backlog); /*成功返回0,否则-1*/

内核为监听套接字维护两个队列:

  1. 未完成连接队列:每个SYN分节对应一项:由Client发送Server,Server等待其完成三路握手。这些套接字处于SYN_RCVD状态。
  2. 已完成连接队列:每个已完成三路握手的client对应其中一项。
    Alt text

每当未完成队列建立一项,来自监听套接字的参数就复制到即将建立的连接中,完全自动。
Alt text
每次调用accept时,返回已完成队列的队头。如果队列为空则进程睡眠直到放入一项再唤醒。

  • listen函数的backlog参数层被规定为两个队列总和最大值
  • berleley实现:backlog * 1.5 = 未处理队列的最大长度
  • 不要把backlog设置为0
  • 如果三路握手正常完成,那么一项在未完成队列停留的时间是一个RTT。
  • 历来backlog设为5,但是对于如今最好设大点。
  • 可以通过环境变量和命令行参数修改避免重新编译,可以重写一个listen函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void 
    Listen(int sockfd, int backlog)
    {
    char *ptr;
    if((ptr = getenv(*LISTENQ*))!=NULL)
    backlog = atoi(ptr);

    if(listen(sockfd,backlog) < 0)
    err_sys("listen error");
    }
  • 如果一个Client SYN到达时,发现队列都满了,TCP忽略分节。等待client重发。

  • 在三路握手完成后,但在Server调用accept之前到达的数据应由Server TCP排队,最大数据为相应已连接套接字的接受缓冲区大小。

Alt text

4.6 accept函数

1
2
3
#include<sys/socket.h>
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
/*若成功返回非负描述符,出错为-1*/
  • 如果accept返回成功则生成一个新的已连接描述符。一个服务器一般只建立一个监听描述符,它在服务器的生命期一直存在。内核为每个服务器进程接受的客户连接创建一个已连接套接字。当服务器完成对某个client的服务后,就关闭已连接套接字。
  • 函数最多返回三个值:新描述符或-1,客户进程协议地址和地址大小(这两个一般为空,因为不关心)。

    4.7 fork和exec函数

    1
    2
    3
    #include<unistd.h>
    pid_t fork(void);
    /*子进程返回0,父进程返回子进程的ID,出错返回-1*/
  • 子进程只有一个父进程,并可以通过getppid获取父进程id,所以返回0

  • 父进程有多个子进程,所以返回子进程id
  • 父进程调用fork前打开的所有描述符由子进程共享。父进程调用accept后fork,已连接socket在父子间共享,通常父亲关闭,子进程接着读写。

fork两个用法

  1. 一个进程创建一个自身副本,这样每个副本都可以在另一个副本工作时进行各自的操作。(网络Server常用)
  2. 一个进程想要执行另一个程序,首先调用fork,然后其中一个副本调用exec把自身替换成新的程序。(shell用法)
  3. 由现有进程调用exec替换成新的程序文件是硬盘上的exe在unix执行的唯一方法。调用exec的进程称为Call process,新程序为new program。

6个exec区别为

  1. 带执行的程序由filename还是pathname指定
  2. 新程序参数是一一给出还是一个指针数组引用
  3. 把调用进程的环境传递给新程序还是给新程序指定新环境。
1
2
3
4
5
6
7
8
#include<unistd.h>
int execl(const char*pathname, const char *arg0,...);
int execv(const char*pathname, char* const argv[]);
int execle(const char*pathname, const char *arg0,...);
int execve(const char*pathname, char* const argv[],char* const envp[]);/*系统调用*/
int execlp(const char*filename, const char *arg0,...);
int execvp(const char*filename, char* const argv[]);
/* 均返回:成功则不返回,出错-1*/

Alt text
Alt text

进程在调用exec前打开的描述符通常跨exec继续打开。

4.8 并发服务器

一个典型的并发服务器的轮廓

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pid_t pid;
int listenfd,connfd;
listenfd = Socket(...);
/* 省略sockaddr*/
Bind(listenfd,..);
Listen(listenfd,LISTENEQ);
for(;;)
{
connfd = Accept(listenfd,...);
if((pid = Fork())==0)
{
Close(listenfd); /*子进程关闭监听描述符*/
doit(connfd); /*子进程处理请求*/
Close(connfd); /*子进程关闭已连接描述符*/
exit(0);
}
Close(connfd); /*父进程关闭已连接描述符*/
}

为什么父进程关闭描述符后没有断开连接?
因为connfd的引用计数,父进程关闭后只是从2减到1,只有等于0才关闭释放。
Alt text
Alt text
并发服务器下一步是fork
Alt text
再下一步子进程关闭监听套接字,父进程关闭已连接套接字。

4.9 close函数

1
2
3
#include<unistd.h>
int close(int sockfd);
/*如成功返回0,否则返回-1*/

close后的描述符不能再由调用进程使用
描述符引用计数
如果想发送fin但是不用close,可以使用shutdown函数。

4.10 getsockname函数和getpeername函数

这两个函数返回与某个套接字相关连的本地协议地址(getsockname),或者返回相关的外地协议地址(getpeername)

1
2
3
4
#include<sys/socket.h>
int getsockname(int sockfd, struct sockaddr * localaddr, socklen_t* addrlen);
int getpeername(int sockfd,struct sockaddr* peeraddr, socklen_t* addrlen);
/* 成功返回0否则-1*/

需要这两个函数的理由:

  1. TCPclient connect返回后,getsockname返回由内核赋予该连接的本地ip和本地端口号。
  2. 在以0号调用bind后,getsockname返回由内核赋予的本地端口号
  3. getsockname可用于获取某个套接字的地址族
  4. 在一个以通配ip调用bind的Server上,与client连接后getsockname可以返回内核赋予该连接的本地ip地址。这样的调用中套接字必须是已连接套接字
  5. 当Server由accept的某个进程通过exec执行程序时,它能获取client身份唯一途径getpeername。
    Alt text
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    /**
    返回套接字的地址族
    */
    #include "unp.h"
    int sockfd_to_family(int sockfd)
    {
    struct sockaddr_storage ss; /*不知道套接字地址结构,使用通用结构*/
    socklen_t len;
    len = sizeof(ss);
    if(getsockname(sockfd,(SA*)&ss,&len)<0) /*适用任何已经打开的套接字 */
    return (-1);
    return (ss.ss_family);

    }

5. TCP C/S程序实例

5.1 目标

实现一个TCP回射服务器

  1. Client从标准输入读入一行文本,并写给Server。
  2. Server从网络输入读入这行文本,并回射给Client。
  3. Client从网络输入读入这行回射文本,并标出用于输入和输出的函数。

Alt text

我们把地址和端口等硬编码到代码,原因是:

  1. 了解地址结构放什么内容
  2. 后续再后面会介绍

5.2 TCP回射Server

5.3 TCP回射Client

5.4 正常启动和关闭

  1. 启动服务端

    1
    ./tcpserv01 &
  2. 使用netstat 查看监听状态。
    Alt text
    发现server正在LISTEN状态

  3. 启动client,并指定server ip为本地环回地址。
    1
    ./tcpcli01 127.0.0.1

客户调用socket和connect,后者引起TCP三路握手的过程。当三路握手完成后,Client的connect和Serveraccept返回,连接建立。
客户调用str_cli函数阻塞与fgets调用。
Server的accept返回,服务器调用fork,子进程调用str_echo。该函数调用readline,readline调用read,而read等待客户送入一行文本期间阻塞。
另外Server父进程再次accept阻塞,等待下一个客户连接。
现在:3个进程睡眠。
Alt text

  1. 然后输入^D(EOF符),fgets返回nullptr,str_cli返回
  2. str_cli返回到main后,main调用exit终止。
  3. 进程终止的部分工作是关闭所有的fd,因此socketfd由内核关闭发送FIN给Server,则TCP发送ACK,这是TCP连接终止的前一半,Server此时CLOSE_WAIT。CLient则FIN_WAIT_2
  4. 当Server TCP接收FIN时,Server子进程阻塞于readline,readline返回0,导致str_echo返回子进程main函数。
  5. 子进程调用exit终止。
  6. 子进程打开的socketfd开始关闭,TCP发送终止的最后两个分节:一个从Server到CLient的FIN和一个从Client到Server的ACK。至此,连接终止进入TIME_WAIT状态。
  7. 服务器子进程终止后时,给父进程发送SIGCHLD信号。但是父进程未捕获子进程僵死。
    Alt text

5.5 POSIX信号处理

Signal就是告知某个进程发生某个事件的通知,有时也叫软件中断。信号通常异步发生,也就是进程预先不知道信号准确发生时刻。
信号可以:

  • 由进程发给另一个进程
  • 也可以由内核发给某个进程。
    通过sigaction来设定信号处置,有三种选择。
  1. signal handler:catch signal。只要有特定的信号就被调用。但是不能捕获sigkill和sigstop
  2. 我们可以把信号处置设为SIG_IGN来忽略它。SIGKILL和SIGSTOP不能忽略。
  3. 把某个信号设为SIG_DFL来启用默认处置。

可以通过编写signal函数替代sigaction。

1
2
3
4
5
6
7
8
9
void sig_chld(int signo)
{
pid_t pid;
int stat;
pid = wait(&stat);
printf("child %d terminated\n",pid);
return ;
}
/*在tcpserv2调用了该函数处理僵死的子进程*/

处理僵死进程
僵死状态是为了维护子进程信息让父进程以后某个时候获取。信息包括子进程的进程ID、状态和资源利用信息。

新的server父进程调用sig_chld,wait取到子进程id和状态,返回。信号在系统阻塞于accept时父进程捕获,内核使accept返回一个enter错误。而父进程不处理该错误。

处理中断的系统调用
慢系统调用指:那些可能永远阻塞的系统调用,如accept,Server的read,对pipe和dev的读写。例外磁盘io一般都会返回。
慢系统调用在捕获信号的时候可能会返回一个EINTR错误,调用中断。
所有需要对这个错误进行处理。添加处理的代码

1
2
3
4
5
6
7
8
9
for(;;)
{
if(connfd = accept(listenfd,(SA *)&cliaddr,&clilen)<0){
if(errno == EINTR)
continue;
else
err_sys("accept error");}
}
}

这段代码可以重启accept,也可用于read、write、select和open等
但是不能用于connect,不能重启

5.6 wait 和 waitpid 函数

1
2
3
4
5
6
7
8
9
10
11
#include<sys/wait.h>
pid_t wait(int *statloc)
pid_t waitpid(pid_t pid,int *statloc,int options)
/* 均返回:若成功则为pid,出错则0或-1 */
pid = 已终止的子进程pid
statloc->子进程终止状态(一个整数)/*可以调用宏检查*/
如果没有已终止的子进程,wait会阻塞到现有子进程第一个终止。

waitpid
pid = 向等待的Pid
options = 允许指定选项(WNOHANG可以告知内核在没有子进程时不要阻塞)

6. I/O复用:select和poll

6.1 关于IO复用

上一章写的client可以处理两个输入:标准输入和socket。
问题在于client阻塞于fgets调用的过程时,server会被kill。虽然TCP Server正确地给Client发送一个FIN,但是client阻塞在fgets,无法看到EOF直到从socket读入,(这阻塞了很长时间)。这需要预先告诉内核,一旦发现多个IO条件准备好就通知应用程序,这个能力称为io复用。这是由selectpoll两个函数支持的。
IO复用的网络场景

  • 当client处理多个描述符,必须使用IO复用。
  • 一个client同时处理多个Socket可能,只是比较少见。
  • 如果TCP既要处理listenfd又要处理connfd一般用io复用
  • 如果一个Server要处理多个服务或多个协议,使用IO复用
    IO复用不止用于网络编程也用于应用程序。

    6.2 IO模型

    5种io模型(Unix)

  • 阻塞io
  • 非阻塞io
  • io复用(select,poll)
  • 信号驱动式io(sigio)
  • 异步io(aio等)
    IO对比
    同步io和异步io
    前四种都是同步io,只有后一种是异步io。
    Alt text

    6.3 select函数

    该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有有一个或多个事件发生或经历指定时间才唤醒它。
    我们利用select能做什么?
    告知内核在下列情况发生后才返回
  • {1,4,5}中任何fd准备好读
  • {2,7}中任何fd准备好写
  • {1,4}中的任何描述符有异常条件待处理。
  • 经历10.2秒
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include<sys/select.h>
#include<sys/time.h>

int select(int maxfdpl, fd_set *readset, fd_set *writeset, fd_set *expectset const struct timeval *timeout);
//如果有就绪的fd=数目,如果超时=0,如果出错=-1
struct timeval{
long tv_sec; //秒
long tv_usec;//微秒
};
timeout:有三种可能。
1.永远等待,直到有一个fd准备好io才返回。timeout==nullptr
2.等待固定时间:有一个fd准备好io返回,但是不能超过timeval中的秒数和微秒数。
3.不等待。检查fd后立即返回,称为polling(轮询)。timeout必须指向timeval且定时器值必须为0
timeval 不会被函数改变
中间三个参数readset,writeset和expectset指定我们要让内核测试读、写和异常条件的描述符。
maxfdpl参数指定待测试的fd个数,=待测试的maxfd + 1 //因为fd从0开始所以 + 1
支持的异常条件
1. 某个Socket的带外数据到达。
2. 某个已置为分组模式的伪终端存在可从其主端读取的控制状态信息。

如何给三个参数指定一个或多个fd?
使用fdset(**描述符集**),通常为一个整数数组,每个整数中的每一位对应一个描述符。

void FD_ZERO(fd_set *fdset);//clear所有bit
void FD_SET(int fd,fd_set *fdset);//置1 fdset数组的fd
void FD_CLR(int fd,fd_set *fdset);//置0 fdset数组的fd
int FD_ISSET(int fd,fd_set *fdset);//判断对应bit是否=1

fd_set rset;
FD_SET(1,&rset);
FD_SET(4,&rset);
FD_SET(5,&rset);

fdset的初始化很重要,自动分配需要初始化。
三个描述符指针都可以设置为空(这时候可用来做sleep()),poll函数提供类似的功能。

使用select的两个常见错误

  • 忘记maxfdpl =maxfd + 1
  • 忘记fdset是值-结果参数(每次重新调用select需要重新赋值)

描述符就绪条件

满足准备好读的情况(有一个即可)

  1. 该Socket接收缓冲区databytes >= 低水位标记大小,返回一个大于0的值
  2. 该连接读半关闭(即接收了FIN的TCP连接)。不阻塞读返回0
  3. listenfd且已完成的连接数不为0。这样的Socket的accept通常不阻塞。
  4. 有一个Socket错误待处理。对这样的Socket的read操作不阻塞并返回-1。并把errno设置成确切的错误条件。这些待处理错误也可以通过SO_ERROR套接字选项调用getsockopt获取并消除。

    接收低水位标记(Receive Low Water Mark),是接收缓冲区的一个限制标记,当接收缓冲区中的数据字节数达到此限制时,读操作可以返回数据(数据已准备好)。

满足准备好写的情况(有一个即可)

  1. 套接字的发送buff中可用空间字节数>=低水位标记大小且或者Socket已连接或不需要连接。
  2. 该连接的写半部关闭。产生SIGPIPE信号。
  3. 使用非阻塞的connect的Socket已建立连接,或者connect已经以失败告终。
  4. 有一个错误待处理,此时不阻塞并返回-1。。并把errno设置成确切的错误条件。这些待处理错误也可以通过SO_ERROR套接字选项调用getsockopt获取并消除。
    满足异常条件
    同上一节的说明,

某个Socket发生错误时,会由select标记为既可读又可写
低水位目的:允许应用进程控制select返回可读和可写条件前有多少数据可读或有多大空间可写。
任何UDP只要发送低水位标记小于等于发送缓冲区大小总可写。因为不需要连接。
Alt text

select最大描述符数约为256,poll能提供更大连接数。