欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

《UNIX网络编程:套接字联网API》啃书笔记(1~5章套接字编程基础)

程序员文章站 2022-07-14 20:33:15
...

SCTP介绍
流控制传输协议(SCTP)为传输层协议,SCTP在客户和服务器之间提高关联,并像TCP那样给应用提高可靠性、排序、流量控制以及全双工的数据传送。
SCTP中使用“关联”一词,一般来说,一个连接只涉及两个IP地址之间的通信。一个关联指代两个系统之间的一次通信,它可能因为SCTP支持多宿而涉及不止两个地址。
SCTP是面向消息的,它提供各个记录的按序传递送服务,与UDP一样,由发送端写入的每条记录的长度随数据一道传递给接收端应用。SCTP能够在所连接的端点之间提供多个流,每个流各自可靠地按序递送消息。一个流上某个消息的丢失不会阻塞同一个关联其他流上消息的投递。
SCTP提供多宿特性,使得单个SCTP端点能够支持多个IP地址。当该端点与另一个端点建立一个关联后,如果它的某个网络或某个跨越因特网的通路发生故障,SCTP就可以通过切换到使用已于该关联相关的另一个地址来规避所发生的故障。

TCP连接的建立与终止

建立连接的三次握手
1. 服务器必须准备好接受外来的连接。这通常通过调用socket、bind、listen这3个函数来完成,称之被动打开。
2. 客户通过调用connect发起主动打开。这导致客户TCP发送一个SYN分节,它告诉服务器客户将在待建立的连接中发送的数据的初始***。
3. 服务器必须确认(ACK)客户的SYN,同时自己也得发送一个SYN分节,它含有服务器在同一连接中发送的数据的初始***。服务器在单个分节中发送SYN和对客户SYN的ACK确认。
4. 客户必须确认服务器的SYN。

断开连接的四次握手
1. 某个应用进程首先调用close,我们称为该端执行主动关闭。该端的TCP发生一个FIN分节表示数据发生完毕。
2. 接收到这个FIN的对端执行被动关闭。这个FIN由TCP确认。它的接收也作为一个文件结束符传递给接收端应用进程(置于已排队等候该应用进程接收的任何其他数据之后),因为FIN的接收意味着接收端应用进程在相应连接上再无额外数据可以接收。
3. 一段时间后,接收到这个文件结束符的应用进程将调用close关闭它的套接字。这导致它的TCP也发送一个FIN。
4. 接收这个最终FIN的原发送端TCP(即执行主动关闭的那一端)确认这个FIN。

步骤2、3间,从执行被动关闭一端到执行主动关闭一端流动数据是可能的,者称为半关闭。

TCP连接的11种状态
客户端:
1 CLOSED
——客户应用进程发起一个TCP连接,发送SYN——>
2. SYN_SENT
——接收SYN&ACK,发送ACK——>
3. ESTABLISHED
连接建立
——客户应用程序发起关闭连接,发送FIN——>
4. FIN_WAIT_1
——接收ACK——>
5. FIN_WAIT_2
——接收FIN,发送ACK——>
6. TIME_WAIT
——等待30秒——>
1. CLOSED
连接关闭,资源被释放

服务器端:
1. CLOSED
——服务器应用程序创建一个监听套接字socket、bind、listen——>
2. LISTEN
——接收SYN并发送SYN&ACK——>
3. SYN_RCVD
——接收ACK——>
4. ESTABLISHED
连接建立
——接收FIN,发送ACK——>
5. CLOSN_WAIT
——发送FIN——>
6. LAST_ACK
——接收ACK——>
2. LISTEN
本次连接关闭,监听客户端的下一次连接请求

TCP连接的分组交换图:
《UNIX网络编程:套接字联网API》啃书笔记(1~5章套接字编程基础)

TIME_WAIT状态

当执行主动关闭端接收到被动关闭端的FIN时,主动关闭端仍需在TIME_WAIT状态停留最长分节生命期(MSL:maximum segment lifetime)的两倍。MSL是任何IP数据报能在因特网中存活的最长时间(就算该分组具有255的最大跳限字段)。

分组迷途:某个路由器奔溃或某两个路由器之间的某个链路断开时,路由协议需花数秒到数分的时间才能稳定并找出另一条通路,在这期间内可能发生路由循环。
若迷途的分组为一TCP分节,在它迷途期间,发送端超时并重传该分组,而重传的分组却通过某条候选路径到达最终目的地,然而不久后路由循环修复,迷失分组最终也被送到目的地。TCP必须正确的处理这些重复的分组。


TIME_WAIT状态有两个存在的理由:
1. 可靠地实现TCP全双工连接的终止;
2. 允许老的重复分节在网络中消逝。

第一个理由在于若最终的ACK丢失了,则服务器将重新发送它的最终那个FIN,因此客户必须维护状态的信息。
第二个理由在于若在关闭这个连接后不久在相同的IP和端口间建立了另一个连接,后一个连接称为前一个连接的化身。TCP必须防止来自某个连接的老的重复的分组在该连接已终止后再现,从而被误解成属于同一连接的某个新的化身,由此TCP将不给处于TIME_WAIT状态的连接发起新的化身。这样我们就能保证每成功建立一个TCP连接时,来自该连接先前化身的老的重复分组都已经在网络中消逝了。

例外:若到达的SYN***大于前一化身的结束***,则将给当前处于TIME_WAIT状态的连接启动新的化身。

端口号
保留端口:0~1023

一个TCP连接的套接字对为一定义该连接的四元组:本地IP、本地端口号、外地IP、外地端口号。套接字对唯一标识一个网络上的每个TCP连接。标识每个端点的两个值(IP和端口号)通常称为一个套接字。

路径MTU:两主机之间的路径中最小的MTU。若IP数据报大小超过相应链路的MTU,则IPv4和IPv6将执行分片,但仅有IPv4路由器会对其转发的数据报分片,IPv6路由器不会。

A到B和B到A的路径MTU可以不一致,因为因特网中的路由选择往往是不对称的。
TCP中最大分节大小MSS的目的是告诉对端其重组缓冲器大小的实际值,从而避免分片。MSS常设置成MTU减IP和TCP首部的固定长度。

TCP输出
每一个TCP套接字有一个发送缓冲区,我们可以使用SO_SNDBUF套接字选项来更改该缓冲区的大小。当某个应用进程调用write时,内核从该应用进程的缓冲区中复制所有数据到所写套接字的发送缓冲区。内核将不从write系统调用返回,直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区。因此,从写一个TCP套接字的write调用成功返回仅仅表示我们可以重新使用原来的应用进程缓冲区,并不表明对端的TCP或应用进程已接收到数据。

TCP必须为已发送的数据保留一个副本,直到它被对端确认为止,此时TCP才能从套接字发送缓冲区中丢弃已确认的数据。

TCP以MSS大小或更小的块把数据传递给IP,同时给每个数据块按上一个TCP首部以构成TCP分节,其中MSS或是由对端通告的值或是536(IPv4最小重组缓冲区576减IPv4首部20和TCP首部20)。

UDP输出
UDP是不可靠的,故而它不必保存应用进程数据的一个副本,因此无需一个真正的发送缓冲区。但任何UDP套接字还是会有缓冲区大小,不过它仅仅是可写到该套接字的UDP数据报的大小上限。

从写一个UDP套接字的write调用成功返回表示所写的数据报或其所有片段已被加入数据链路层的输出队列。若该队列没有足够的空间存放该数据报或它的某个片段,内核通常会返回一个ENOBUFS错误,但也有可能未经发送直接丢弃!

套接字编程基础

IPv4套接字地址结构

struct in_addr {
    in_addr_t s_addr;       //32bit IPv4地址
};
struct sockaddr_in{
    uint8_t sin_len;        //结构体长度(>=16)
    sa_family_t sin_family; //地址族,AF_INET,unsigned short
    in_port_t sin_port;     //16bit端口号
    struct in_addr sin_addr;//32bit IPv4地址
};

通用套接字结构

struct sockaddr{
    unit8_t sa_len;
    sa_family_t sa_family; //地址族,AF_xxx 
    char sa_data[14];      //协议规范地址
};
int bind(int,struct sockaddr *,socklen_t);          //接收参数为通用套接字地址结构指针
strcut sockaddr_int serv;                           //声明一个IPv4套接字地址结构
bind(sockfd,(struct sockaddr *)&serv,sizeof(serv)); //使用时需要强制类型转换
  1. 从进程到内核传递套接字地址结构的函数有3个:bind、connect、sendto.
  2. 从内核到进程传递套接字地址结构的函数有4个:accept、recvfrom、getsockname、getpeername.

字节操纵函数

#include<strings.h>
//bzero把目标字节串中指定数目的字节置为0
void bzero(void *dest,size_t nbytes);
//bcopy将指定数目的字节从源字节串移到目标字节串
void bcopy(const void *src,void *dest,size_t nbytes);
//bcmp比较两个任意字节串,若相同返回0,否则返回非0
int bcmp(const void *ptr1,const void *ptr2,size_t nbytes);
#include<string.h>
//memset把目标字节串指定数目的字节置为值c
void *memset(void *dest,int c,size_t len);
//memcpy类似bcopy,但当源字节串与目标字节串重叠时memcpy的操作结果不可知
void *memcpy(void *dest,const void *src,size_t nbytes);
//memcmp比较两个任意的字节串,若相同返回0,否则取决第一个不等的字节返回值大于0或小于0
int memcmp(const void *ptr1,const void *ptr2,size_t nbytes);

地址转换函数
函数名中的a表地址,n表数值,p表表达(presentation)
适用IPv4:

#include<arpa/inte.h>
//inet_aton将strptr所指的字符串转换成一个32位的网络字节序二进制值,并通过addrptr来存储,若成功返回1,否则返回0
//若addrptr指针为空,则该函数仍然对输入的字符串执行有效性检查,但不存储任何结果
int inet_aton(const char *strptr,struct in_addr *addrptr);

//inet_addr进行相同的转换,返回值为32位的网络字节序二进制值,该函数已废弃
//出错时该函数返回INADDR_NONE常值
in_addr_t inet_addr(const char *strptr);

//inet_ntoa函数将一个32位的网络字节序二进制IPv4地址转换成相应的点分十进制数串
char *inet_ntoa(struct in_addr inaddr);

IPv4和IPv6都适用:

#include<arpa/inet.h>
//参数family为AF_INET或AF_INET6,否则返回错误
//inet_pton转换由strptr指针所指的字符串,并通过addrptr指针存放二进制结果。
//若成功返回1,否则若输入的字符串不是有效表达格式则返回0,出错返回-1
int inet_pton(int family,const char *strptr,void *addrptr);

//inet_ntop进行相反转换,若成功则返回结果的指针,出错则返回NULL
//strptr参数不可以是一个空指针,调用者必须为目标存储单元分配内存并指定其大小。调用成功时此指针即返回值
const char *inet_ntop(int family,const void *addrptr,char *strptr,size_t len);

//其中len的大小
#include<netinet/in.h>
#define INET_ADDRSTRLEN 16      //对于IPv4
#define INET6_ADDRSTRLEN 46     //对于IPv6

TCP套接字编程基础

基本TCP客户/服务器程序的套接字函数:
《UNIX网络编程:套接字联网API》啃书笔记(1~5章套接字编程基础)

socket函数
为了执行网络I/O,一个进程必须做的第一件事情就是调用socket函数,指明期望的通信协议类型。

#include<sys/socket.h>
int socket(int family,int type,int protocol);

若成功返回非负描述符,出错则返回-1。

其中family参数指明协议族:

AF_INET         IPv4协议
AF_INET6        IPv6协议
AF_LOCAL        unix域协议
AF_ROUTE        路由套接字
AF_KEY          **套接字

type参数指明套接字类型

SOCK_STREAM     字节流套接字(TCP/SCTP)
SOCK_DGRAM      数据报套接字(UDP)
SOCK_SEQPACKET  有序分组套接字(SCTP)
SOCK_RAW        原始套接字(IP)

protocol参数设为某个协议类型常值,或设为0以选择所给定family和type组合的系统默认值

IPPROTO_TCP     TCP传输协议
IPPROTO_UDP     UDP传输协议
IPPROTO_SCTP    SCTP传输协议

socket函数在成功时返回一个小的非负整数值,它与文件描述符类似,我们称之为套接字描述符,简称sockfd。我们只需要指定协议族(IPv4或IPv6)和套接字类型即可。

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

#include<sys/socket.h>
int connect(int sockfd,const struct sockaddr *servaddr,socklen_t addlen);

若成功返回0,出错则返回-1。

sockfd是由socket函数返回的套接字描述符,第二个、第三个参数分别是一个指向套接字地址结构的指针和该结构的大小,其中套接字地址结构必须含有服务器的IP地址和端口号。
客户在调用函数connect前不必非得调用bind函数,内核会确定源IP地址,并选择一个临时端口作为源端口。

若是TCP套接字,调用connect函数将激发TCP的三路握手过程,且仅在连接建立成功或出错时才返回。

connect错误返回情况:
1. ETIMEDOUT:客户没有收到SYN分节的响应
2. ECONNREFUSED:服务器端口无进程与之连接,此时客户收到的响应为RST
3. EHOSTUNREACH或ENETUNREACH:目的地不可达,ICMP错误

RST是TCP在发生错误时发送的一种TCP分节。产生RST的三个条件是:
1. 目标端口无正在监听的服务器
2. TCP想取消一个连接
3. TCP接收到一个根本不存在连接上的分节

connect函数导致当前套接字从CLOSED状态转移到SYN_SENT状态,若成功则再转移到ESTABLISHED状态。若connect失败则该套接字不再可用,都必须close当前的套接字描述符并重新调用socket,我们不能对这样的套接字再次调用connect函数。

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

#include<sys/socket.h>
int bind(int sockfd,const struct sockaddr *myaddr,socklen_t addrlen);

若成功返回0,出错则返回-1。

第二个参数是一个指向特定于协议的地址结构的指针,第三个参数是该地址结构的长度。对于TCP,调用bind函数可以指定一个端口号,或指定一个IP地址,也可以两者都指定,还可以都不指定。

服务器在启动时捆绑它们的总所周知的端口。若一个TCP客户或服务器未曾调用bind捆绑一个端口,当调用connect或listen时,内核就要为相应的套接字选择一个临时端口。

进程可以把一个特定的IP地址捆绑到它的套接字上,不过这个IP地址必须属于其所在主机的网络接口之一。对TCP客户,这是指派了源IP地址,对于TCP服务器,这是限定了套接字只接受那些目的地为这个IP地址的客户连接。若服务器未捆绑IP地址,内核就把客户发送的SYN目的IP作为服务器的源IP。

如果指定端口号为0,则内核就在bind被调用时选择一个临时端口。若指定IP地址为通配地址,则内核将等待套接字已连接(TCP)或已在套接字上发出数据报(UDP)时才选择一个本地IP地址。

对于IPv4,通配地址为INADDR_ANY,值一般为0;对于IPv6,系统预分配 in6addr_any (在头文件<netinet/in.h>)变量并将其初始化为常值IN6ADDR_ANY_INIT

若让内核来为套接字选择一个临时端口号,注意bind函数并不返回所选择的值,我们必须调用函数getsockname来返回协议地址。

listen函数
listen函数仅由TCP服务器调用。它把socket函数创建的主动套接字转换成一个被动套接字,即指示内核应接受指向该套接字的连接请求并规定了最大连接数。调用listen导致套接字从CLOSED状态转换到LISTEN状态。

#include<sys/socket.h>
int listen(int sockfd,int backlog);

若成功返回0,出错返回-1。

第二个参数规定了内核应该为相应套接字排队的最大连接个数。成功返回0,出错返回-1。

不要把backlog定义为0!若不想让任何客户连接到你的监听套接字上,那就关掉该监听套接字。

本函数通常在调用socket和bind函数之后,并在调用accept函数之前调用。

内核为任何一个给定的监听套接字维护两个队列:
1. 未完成连接队列:客户的SYN已发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程,这些套接字正处于SYN_RCVD状态。
2. 已完成连接队列:每个已完成TCP三路握手过程的客户对应其中一项,这些套接字处于ESTABLISHED状态。

当来自客户的SYN到达时,TCP在未完成连接队列中创建一个新项,然后响应以三路握手的第二个分节SYN&ACK。这一项一直保留在未完成连接队列中,直到三路握手的第三个分节到达或该项超时为止。若三路握手正常完成,该项就从未完成连接队列移到已完成连接队列的队尾。当进程调用accept时,已完成连接队列中的队头项将返回给进程,或者如果该队列为空,那么进程将被投入睡眠,直到TCP在该队列中放入一项才唤醒它。

在三次握手正常完成的前提下,未完成连接队列中的任何一项在其中存留的时间就是一个RTT。当一个客户SYN到达时,若这些队列是满的,TCP就忽略该分节,期望客户重发SYN时能有可用空间。

accept函数
accept函数由TCP服务器调用,用于从已完成连接队列队头返回下一个已完成连接。如果已完成连接队列为空,那么进程被投入睡眠。

#include<sys/socket.h>
int accept(int sockfd,struct sockaddr *cliaddr,socklen_t *addrlen);

若成功返回非负描述符,出错返回-1。

参数cliaddr和addrlen用来返回已连接的对端进程的协议地址。addrlen是值-结果参数:调用前为cliaddr所指的套接字地址结构的长度,返回时为内核存放在该套接字地址结构内的确切字节数。

若accept成功,那么其返回值是由内核自动生成的一个全新描述符,代表与所返回客户的TCP连接。称第一个参数sockfd为监听套接字描述符,称它的返回值为已连接套接字描述符。注意一个服务器通常仅仅创建一个监听套接字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建一个已连接套接字。当服务器完成对某个给定客户的服务时,相应的已连接套接字就被关闭。

本函数最多返回三个值:
1. 一个既可能是新套接字描述符也可能是出错指示的整数
2. 客户进程的协议地址(由cliaddr指针所指)
3. 该地址的大小(由addrlen指针所指)

若我们对返回客户协议地址不感兴趣,那么可以把cliaddr和addrlen均置为空指针。

fork和exec函数
fork函数时unix中派生新进程的唯一方法。

#include<unistd.h>
pid_t fork(void);

fork函数调用一次,返回两次。它在调用进程(称父进程)中返回一次,返回值为新派生进程(称子进程)的进程ID号;在子进程又返回一次,返回值为0。由此返回值本身告知当前进程是子进程还是父进程。

fork在子进程返回0而不是父进程的进程ID的原因在于:任何子进程只有一个父进程,而且子进程总是可以通过调用getppid取得父进程的进程ID。相反,父进程可以有许多子进程且无法获取各个子进程的进程ID。若父进程想要跟踪所有子进程的进程ID,那么它必须记住每次调用fork的返回值。

父进程中调用fork之前打开的所有描述符在fork返回之后由子进程分享,故而可以在父进程调用accept之后调用fork,所接受的已连接套接字随后就在父进程与子进程之间共享。如此子进程可以接着读写这个已连接套接字,父进程可以关闭。

fork的两种典型用法:
1. 一个进程创建一个自身的副本,这样每个副本都可以在另一个副本执行其他任务的同时处理各自的操作。
2. 一个进程执行另一个程序,首先调用fork创建一个自身的副本,然后其中一个副本调用exec把自身替换成新的程序。

exec函数
存放在硬盘上的可执行程序文件能够被unix执行的唯一方法是:由一个现有进程调用六个exec函数中的某一个。exec把当前进程映像替换成新的程序文件,且该新程序通常从main函数开始执行。进程ID并不改变。我们称调用exec的进程为调用进程,称新执行的程序为新程序。

6个exec函数之间的区别在于:
1. 待执行的程序文件是由文件名还是由路径名指定
2. 新程序的参数是一一列出还是由一个指针数组来引用
3. 把调用进程的环境传递给新程序还是给新程序指定新的环境

#include<unistd.h>
int execl(const char *pathname,const char *arg0,.../* *char *) 0 */);
int execv(const char *pathname,char *const *argv[]);
int execle(const char *pathname,const char *arg0,.. /* (char *) 0,char *const envp[] */);
int execve(const char *pathname,char *const argv[],char *const envp[]);
int execlp(const char *filename,const char *arg0,.../* (char *) 0 */);
int execvp(const char *filename,char *const argv[]);

这些函数只在出错时才返回到调用者-1,否则不返回,控制将被传递给新程序的起始点,通常就是main函数。

六个exec函数的关系:
《UNIX网络编程:套接字联网API》啃书笔记(1~5章套接字编程基础)

只有execve是内核中的系统调用,其它5个都是调用execve的库函数。独立参数的函数必须包含一个空指针结束可变数量的这些参数。而argv数组必须含有一个用于指定其末尾的空指针。filename参数的exec将使用当前的PATH环境变量把该文件名参数转换为一个路径名,但若filename参数中含有一个斜杠(/),就不再使用PATH环境变量。若不显式指定一个环境指针(envp),则它们使用外部环境environ的当前值来构造一个传递给新程序的环境列表,若显式指定,其envp指针数组必须以一个空指针结束。

close函数
close函数可以用来关闭套接字,并终止TCP连接。

#include<unistd.h>
int close(int sockfd);

若成功返回0,出错返回-1。

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

一个简单的并发服务器

pid_t pid;
int listenfd,connfd;
listenfd=socket(...);               //生成套接字描述符
bind(listenfd,...);                 //绑定IP地址与端口号
listen(listenfd,LISTENQ);           //转换为监听套接字
while(true){
    connfd=accept(listenfd,..);     //开始连接,生成已连接套接字
    if((pid=fork())==0) {           //生成子进程,并在子进程中运行
        close(listenfd);            //关闭子进程中的监听套接字
        //run(connfd)
        close(connfd);              //运行完毕,关闭子进程的已连接套接字
        exit(0);                    //退出子进程
    }
    close(connfd);                  //生成子进程后父进程关闭已连接套接字,让子进程去运行,父进程继续监听
}

子进程总可以不用调用close关闭连接,进程终止处理的部分工作就是关闭所有由内核打开的描述符。而每个文件或套接字都有一个引用计数可以确保对listenfd和connfd调用close不会把父/子进程的相应套接字终止。若确实想在某个TCP连接上发送一个FIN,那么可以改用shutdown函数。

若父进程对每个由accept返回的已连接套接字都不调用close,那么在并发服务器中:
1. 父进程最终将耗尽可用描述符,因为任何进程在任何时刻可拥有的打开描述符数通常有限
2. 父进程永不关闭任何已连接套接字,导致没有一个客户连接会被终止

getsockname函数和getpeername函数
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);

若成功返回0,出错返回-1。

两个函数都是值-结果参数,也就是说,参数必须是由localaddr或peeraddr指针所指的套接字地址结构。

getsockname:
1. 在一个没有调用bind的TCP客户上,connect成功返回后,getsockname用于返回由内核赋予该连接的本地IP地址和本地端口号
2. 在以端口号0调用bind(告知内核去选择本地端口号)后,getsockname用于返回由内核赋予的本地端口号
3. 在一个以通配IP地址调用bind的TCP服务器上,与某个客户的连接一旦建立(accept成功返回),getsockname就可以用于返回由内核赋予该连接的本地IP地址,其中的套接字描述符参数必须是已连接套接字的描述符
4. 用于获取某个套接字的地址族,适合任何已打开的套接字描述符

若不知道要分配的套接字地址结构的类型,可以采用struct sockaddr_storage 这个通用结构,它能承载系统支持的任何套接字地址结构

getpeername:
当一个服务器是由调用accept的某个进程通过调用exec执行程序时,它能够获取客户身份(即客户IP地址及端口号)的唯一途径便是调用getpeername函数

TCP客户/服务器程序示例

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

《UNIX网络编程:套接字联网API》啃书笔记(1~5章套接字编程基础)

回射服务器程序

//main函数
int main(int argc, char **argv){
    int listenfd,connfd;
    pid_t childpid;
    socklen_t clilen;
    struct sockaddr_in cliaddr,servaddr;               //创建一个IPv4的套接字地址结构

    listenfd=socket(AF_INET,SOCK_STREAM,0);            //创建一个IPv4的TCP套接字

    bzero(&servaddr,sizeof(servaddr));                 //初始化地址结构
    servaddr.sin_family=AF_INET;                       //地址族为IPv4
    servaddr.sin_addr.s_addr=htonl(INADDR_ANY);        //通配的本地IP地址
    servaddr.sin_port=htons(9877);                     //本地众所周知的端口设置9877

    bind(listenfd,(struct sockaddr*) &servaddr,sizeof(servaddr)); //绑定协议地址到套接字

    listen(listenfd,LISTENQ);                          //装换成一个监听套接字

    while(true){
        clilen=sizeof(cliaddr);
        connfd=accept(listenfd,(struct sockaddr*)&cliaddr,&clilen); //连接建立,创建连接套接字

        if((childpid=fork())==0){                     //对每个客户创建子进程并在子进程中执行程序
            close(listenfd);                          //关闭子进程中的监听套接字
            str_echo(connfd);                         //处理客户的服务
            exit(0);                                  //执行完毕关闭子进程           
        }
        close(connfd);                                //关闭父进程中连接套接字,继续监听下一个客户的连接请求
    }
}
//服务器中处理客户服务的str_echo函数
void str_echo(int sockfd){
    ssize_t n;
    char buf[MAXLINE];

again:
    while((n=read(sockfd,buf,MAXLINE))>0)           //从套接字中读入数据
        write(sockfd,buf,n);                        //把内容回射给客户

    if(n<0 && errno==EINTR)
        goto again;
    else if(n<0)                                    //非正常关闭时弹出错误并推出
        err_sys("str_echo:read error");
}

回射客户程序

//main函数
int main(int argc,char **argv){
    int sockfd;
    struct sockaddr_in  servaddr;

    if(argc!=2)                                      //客户必须输入IP地址
        err_quit("usage:tcpcli<IPaddress>");

    sockfd=socket(AF_INET,SOCK_STREAM,0);            //创建IPv4的TCP套接字 

    bzero(&servaddr,sizeof(servaddr));
    servaddr.sin_family=AF_INET;
    servaddr.sin_port=htons(9877);                   //把服务器众所周知端口9877填入地址结构
    inet_pton(AF_INET,argv[1],&servaddr.sin_addr);   //转换IP地址字符串成二进制的IPv4地址

    connect(sockfd,(struct sockaddr*)&servaddr,sizeof(servaddr));  //建立与TCP的连接

    str_cli(stdin,sockfd);                           //客户处理服务器的回射值,客户从标准输入中读入数据
    exit(0);
}

str_cli函数从标准输入读入一行文本,写到服务器上,读回服务器对该行的回射,并把回射行写到标准输出上。

//处理服务器回射值的str_cli函数
void str_cli(FILE *fp,int sockfd){
    char sendline[MAXLINE],recvline[MAXLINE];

    while(fgets(sendline,MAXLINE,fp)!=NULL){        //从fp中读入文本,当遇到结束符或错误时终止循环
        write(sockfd,sendline,strlen(sendline));    //把文本发送给服务器

        if(read(sockfd,recvline,MAXLINE)==0)        //从服务器读入回射行
            err_quit("str_cli:server terminated prematurely");

        fputs(recvline,stdout);                    //把回射行写到标准输出
    }
}

正常启动

  1. 服务器启动:调用socket、bind、listen、accept,然后等待客户连接(netstat可以检查服务器监听套接字的状态)
  2. 客户启动,指定服务器主机IP地址
  3. 客户调用socket和connect,后者引起TCP的三次握手过程,当三次握手完成后,客户中的connect和服务器中的accept均返回,连接于是建立
  4. 客户调用str_cli函数,等待用户输入文本
  5. 当服务器的accept返回时,服务器调用fork,再由子进程调用str_echo,等待客户送入文本
  6. 同时服务器父进程再次调用accept并等待下一个客户连接

客户接收到三次握手的第二个分节时,connect返回,而服务器accept要直到接收到三次握手的第三个分节才返回,即在connect返回之后再过一半RTT才返回

同一主机运行服务器/客户时netstat检查结果:
《UNIX网络编程:套接字联网API》啃书笔记(1~5章套接字编程基础)

linux这些进程的状态和关系:
《UNIX网络编程:套接字联网API》啃书笔记(1~5章套接字编程基础)

第一个tcpserv01位父进程,第二个tcpserv01位为子进程,tcpcli01位客户进程
三个网络进程的STAT列都是”S”表进程在为等待某些资源而睡眠,”Z”表进程已僵死。Linux在进程阻塞与accept或connect时输出wait_for_connect;在进程阻塞与套接字输入或输出时输出tcp_data_wait;在进程阻塞与终端I/O时输出read_chan。

正常终止

  1. 键入EOF字符时,fgets返回空指针,str_cli函数返回
  2. 客户的main函数调用exit终止
  3. 内核关闭客户打开的套接字及所有打开的文件描述符。使得客户TCP发送一个FIN给服务器,服务器TCP则以ACK响应。至此服务器套接字处于CLOSE_WAIT状态,客户套接字处于FIN_WAIT_2状态
  4. 服务器TCP接收FIN,read返回0使得str_echo函数返回服务器子进程的main函数
  5. 服务器子进程通过调用exit终止
  6. 服务器子进程描述符关闭,服务器发送一个FIN,客户回应ACK。至此连接完全终止,客户套接字处于TIME_WAIT状态
  7. 服务器子进程终止,给父进程发送一个SIGCHLD信号

POSIX信号处理

信号(signal)就是告知某个进程发生了某个事件的通知。信号通常是异步发生的,信号可以由一个进程发给另一个进程(或自身)、或由内核发给某个进程。

每个信号都有一个与之关联的处置(或称行为)。我们通过调用sigaction函数来设定一个信号的处置,并由三种选择:

  1. 捕获信号:提供一个信号处理函数,只要有特定信号发生它就被调用。但SIGKILL和SIGSTOP信号无法被捕获。信号处理函数由信号值这个单一的整数参数来调用,且没有返回值,其函数原型为void handler(int signo);
  2. 忽略:把某个信号的处置设定为SIG_IGN。但SIGKILL和SIGSTOP信号不能被忽略
  3. 默认:把某个信号的处置设定为SIG_DFL。默认处置通常是在收到信号后终止进程

重写signal函数
建立信号处置的POSIX方法就是调用sigaction函数,但该函数参数复杂。简单的方法就是调用signal函数,其第一个参数是信号名,第二个参数或为指向函数的指针,或为常值SIG_IGN或SIG_DFL。但signal函数信号语义不同,由此通过包裹sigaction函数来重写signal函数:

//函数signal的正常函数原型是
void (*signal(int signo,void (*func)(int)))(int);

//层次太复杂,于是我们定义如下结构说明信号处理函数是仅有一个整数参数且不返回值的函数
typedef void Sigfunc(int);
//接收信号名和信号处理函数,返回旧的行为
Sigfunc *signal (int signo,Sigfunc *func){
    struct sigaction act,oact;
    //设置处理函数
    act.sa_handler=func;
    //设置处理函数的信号掩码
    //把sa_mask成员设置为空集使得在该信号处理函数运行期间,不阻塞额外的信号
    sigemptyset(&act.sa_mask);
    //设置标志(可选)
    act.sa_flags=0;
    //调用sigaction函数
    if(sigaction(signo,&act,&oact)<0) 
        return (SIG_ERR);
    //oact为信号的旧行为
    return (oact.sa_handler);
}
  1. 一旦安装了信号处理函数,它便一直安装着
  2. 在一个信号处理函数运行期间,正被递交的信号是阻塞的,且安装处理函数时在传递给sigaction函数的sa_mask信号集中指定的任何额外信号也被阻塞。任何阻塞的信号都不能递交给进程
  3. 如果信号在被阻塞期间产生了一次或多次,那么该信号被解阻塞之后通常只递交一次
  4. 可以利用sigprocmask函数选择性地阻塞或解阻塞一组信号

wait和waitpid函数
设置僵死状态的目的是维护子进程的信息,以便父进程在以后某个时候获取,这些信息包括子进程的进程ID、终止状态及资源利用信息。若一个进程终止,而该进程有子进程处于僵死状态,那么它的所有僵死子进程的父进程ID将被重置为1(init进程)。继承这些子进程的init进程将清理它们。可以通过建立一个捕获SIGCHLD信号的信号处理函数,在函数体中调用wait或waitpid处理僵死进程。

#include<sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid,int *statloc,int options);

两函数返回已终止子进程的进程ID号,若出错则返回0或-1,另外还通过statloc指针返回子进程终止状态(一个整数)。

如果调用wait的进程没有已终止的子进程,不过有一个或多个子进程仍在执行,那么wait将阻塞到现有子进程的第一个终止为止。

waitpid函数的pid参数允许我们指定想等待的进程ID,值-1表示等待第一个终止的子进程;options参数允许我们指定附加选项,常用选项WNOHANG告知内核在没有已终止子进程时不要阻塞。

在SIGCHLD信号的信号处理中使用如下代码防止僵死进程存留:

while((pid=waitpid(-1,&stat,WNOHANG))>0) //...;

-1获取第一个终止的进程,WNOHANG告知在有尚未终止的子进程在运行时不要阻塞,如此可以防止僵死进程。若使用wait则当有多个客户同一时刻终止时,导致同一时刻有5个SIGCHLD信号递交给父进程,而信号处理函数只执行一次而留下四个僵死进程,将wait放入循环中也不行,因为无法防止wait在正运行的子进程尚有未终止时阻塞。

慢系统调用(永远阻塞的系统调用):调用时可能永远无法返回。如accept函数
基本规则:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个EINTR错误

长长的废话

计算机网络各层对等实体间交换的单位信息称为协议数据单元(PDU:protocol data unit),分节(segment)就是对应于TCP传输层的PDU。除了最底层(物理层)外,每层的PDU通过由紧邻下层提供给本层的服务接口,作为下层的服务数据单元(SDU:service data unit)传递给下层,并由下层间接完成本层的PDU交换。若本层的PDU大小超过紧邻下层的最大SDU限制,那么本层还要事先把PDU划分成若干个合适的片段让下层分开载送,再在相反方向把这些片段重组成PDU。同一层内SDU作为PDU的净荷(payload)字段出现,上层PDU由本层PDU通过其SDU字段承载。每层的PDU除用于承载紧邻上层的PDU外,也用于承载本层协议内部通信所需的控制信息。

应用层实体(如客户或服务器进程)间交换的PDU称为应用数据(application data),其中在TCP应用进程之间交换的是没有长度限制的单个双向字节流,在UDP应用进程直接交换的是其长度不超过UDP发生缓冲区大小的单个记录,在SCTP应用进程之间交换的是没有总长度限制的单个或多个双向记录流。传输层实体间交换的PDU称为消息,其中TCP的PDU特称为分节。消息或分节的长度是有限的。在TCP传输层中,发送端TCP把来自应用进程的字节流数据按顺序经分割后封装在各个分节中传送给接收端TCP,其中每个分节所封装的数据既可能是发送端应用进程单词输出操作的结果,也可能是连续数次输出操作的结果,而且每个分节所封装的单次输出结果或者首尾两次输出操作的结果既可能是完整的,也可能是不完整的,具体取决于可在连接建立阶段由对端通告的最大分节大小(MSS:maximum segment size)以及外出接口的最大传输单元(MTU:maximum transmission unit)或外出路径的路径MTU。

UDP传输层相当简单,发送端UDP就把来自应用进程的单个记录整个封装在UDP消息中传送给接收端UDP。SCTP引入了称为块(chunk)的数据单元,SCTP消息就由一个公共首部加一个或多个块构成:公共首部类似UDP消息的首部,仅仅给出源目的端口号和整个SCTP消息的校验和;块则既可以承载数据,也可以承载控制信息。发送端SCTP把来自应用进程的一个或多个记录流数据按照流内顺序和记录边界封装在各个DATA块中,并在DATA块首部记上各自的流ID。接收端SCTP收取后把它们组合成单个记录上传,作为传输层PDU的SCTP消息既可以只包含单个块,也可以在接口MTU或路径MTU的限制下包含多个块(称为块的捆绑,控制块在前,DATA块在后)。SCTP收发两端均独立处理捆绑在同一个消息中的各个块。

网络层实体间交换的PDU称为IP数据报,其长度有限:IPv4最大65535字节,IPv6最大65575字节。发送端IP把来自传输层的消息(或TCP分节)整个封装在IP数据报中传送。链路层实体间交换的PDU称为(frame),其长度取决于具体的接口。过长的IP数据报无法封装在单个帧中,需要先对其SDU进行分片,分片操作既可能发生在源端,也可能发生在途中,而其逆操作即重组(reassembly)一般只发生在目的端。TCP/IP协议族为提高效率会尽量避免IP的分片和重组:TCP根据MSS和MTU限定每个分节的大小以及SCTP根据MTU分片/重组过长记录(SCTP的块捆绑则是为了在避免IP分片/重组操作的前提下提高块传输效率),I另外,Pv6禁止在途中的分片操作(基于路径MTU发现)。