UNIX网络编程卷一 学习笔记 第四章 基本TCP套接字编程

并发服务器是在同时有大量客户连接到同一服务器上时用于提供并发性的一种常用Unix技术,使用fork函数实施的是每客户单进程模型,还有每客户单线程的另一种模型。

下图给出了在一对TCP客户与服务器进程间发生的典型事件的时间表,服务器首先启动,稍后客户启动,它试图连接到服务器。我们假设客户给服务器发送一个请求,服务器处理该请求,并且给客户发回一个响应,这个过程持续到客户关闭连接,从而给服务器进程发送一个EOF通知为止。
在这里插入图片描述
为执行网络IO,要先调用socket函数指定期望的通信协议类型(如使用IPv4的TCP、使用IPv6的UDP、Unix域字节流协议等):
在这里插入图片描述
family参数指明协议族,也被称为协议域:
在这里插入图片描述
type参数指明套接字的类型:
在这里插入图片描述
protocol参数应设为以下所示的某个协议类型常值:
在这里插入图片描述
protocol参数也可设为0,表示给定的family和type组合的系统默认值。

并非所有family和type的组合都有效,下图给出了有效的组合和其对应的协议,标为是的项也是有效的,而空白格是无效组合:
在这里插入图片描述
你可能也会碰到socket函数的第一个参数的相应的FP_XXX常值。也可能会碰到AF_LOCAL(POSIX名称)被AF_UNIX取代(历史上的Unix域名称)。

参数family和type还有其他值,如4.4 BSD支持的family参数值还有AF_NS(Xerox NS协议,常称为XNS)和AF_ISO(OSI协议),但现在很少有人使用这些协议。Xerox NS协议和OSI协议都实现了对SOCK_SEQPACKET这个type参数值的支持,该type参数值还可用于SCTP。TCP是一个字节流协议,仅支持SOCK_STREAM作为套接字类型(即type参数值)。

Linux支持一个新的套接字类型SOCK_PACKET,它与BPF(Berkeley Packet Filter,伯克利包过滤器)和DLPI(Data Link Provider Interface,数据链路提供者接口)类似,支持对数据链路的访问。

密钥套接字AF_KEY比较新,用于支持基于加密的安全性。跟路由套接字AF_ROUTE是内核中路由表的接口类似,密钥套接字是内核中密钥表的接口。

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

AF_前缀表示地址族,PF_前缀表示协议族,历史上的想法是:单个协议族可以支持多个地址族,PF_值用来创建套接字,而AF_值用于套接字地址结构,但实际上,支持多个地址族的协议族从来未实现过,而且头文件sys/socket.h中给定协议的PF_值总是与AF_值相等,尽管这种相等关系不一定永远成立,如果有人试图改变这种相等关系,则许多现存代码将崩溃,为与现存代码保持一致,我们本书中仅使用AF_常值,尽管在各种实现代码中,可能会碰到用PF_值调用sockset。

在BSD/OS 2.1版中调用socket的137个程序中,有143个调用指定AF_值,仅有8个调用指定PF_值。

从历史上说,AF_前缀与PF_前缀具有相似常值集的原因要追溯到4.1cBSD,是比我们正在讲述的随4.2 BSD出现的socket函数更早的版本。4.1cBSD版本的socket函数采用了4个参数,其中一个是指向sockproto结构的指针,该结构的第一个成员名为sp_family,它的值时某个PF_值,它的第二个成员sp_protocol是一个协议号,类似于现行socket函数的第3个参数,指定协议族的唯一方法是指定该结构。因此在这个早期系统中,PF_值用来在sockproto结构中指定协议族的结构标签,而AF_值用来在套接字地址结构中指定地址族的结构标签。4.4 BSD中仍有sockproto结构,但仅由内核在内部使用。在最初的定义中,对sockproto结构的sp_family成员有protocol family的注释,在4.4 BSD源码中已改为address family了。

令人更弄不清AF_常值与PF_常值之间区别的是,Berkely实现的一个内核数据结构的成员值(domain.dom_family)会与socket函数的第一个参数作比较,而domain.dom_family有这样的注释它含有AF_值,尽管如此还是有部分domain.dom_family被初始化为PF_值。

4.2 BSD的socket函数手册页面将该函数的第一个参数称为af,并把AF_常值作为它的可能取值列出。然而POSIX在addrinfo结构中却只定义了一个族值,既用于调用socket函数,也用于套接字地址结构中。

TCP客户用connect函数建立与TCP服务器的连接:
在这里插入图片描述
sockfd参数是由socket函数返回的套接字描述符,servaddr参数是指向套接字地址结构的指针(该结构中含有服务器的IP和端口),addrlen参数是该结构的大小。

客户调用connect前不必非得调用bind函数,因为此时内核会确定源IP地址,并选择一个临时端口作为源端口。

如果是TCP套接字,调用connect函数会触发TCP的三次握手过程,connect函数仅在连接建立成功或出错时才返回,出错返回的可能情况:
1.若TCP客户未收到SYN分节的响应,返回ETIMEDOUT错误。调用connect函数后,举例来说,4.4 BSD内核发送一个SYN,若无响应则等待6s后再发送一个,若还无响应则等待24s再发一个,总共等待75s后还未收到响应则返回本错误。有些系统提供对超时值的控制。
2.若客户收到的SYN响应是RST,表明该服务器主机的指定端口上没有进程在等待与之连接。这是一种硬错误,此时connect函数马上返回ECONNREFUSED错误。RST是TCP发生错误时发送的一种TCP分节,RST产生的条件如下:
(1)目的地某端口上SYN到达,但该端口上没有正在监听的服务器。
(2)TCP想取消一个已有连接。
(3)TCP接收到一个根本不存在的连接上的分节。
3.若客户发出的SYN在中间某路由器上引发了目的地不可达的ICMP错误,则认为这是一种软错误,此时客户主机内核保存该消息,并按1中的时间间隔继续发送SYN(因为ICMP错误可能指示某个暂时状态,它可能是由于某个可被修复的原因引起的),若在75s后还未收到响应,则把保存的ICMP错误作为EHOSTUNREACH或ENETUNREACH返回给进程。出现该错误也可能是本机转发表没有到达远程系统的路径或connect调用不等待就返回(非阻塞)。

许多早期系统(如4.2 BSD)在收到目的地不可达ICMP错误时会不正确地放弃建立连接的尝试,这种做法不正确的原因是,该错误可能指示某个暂时状态,如它可能是可以被修复的某个路由问题导致。

即使ICMP错误指示目的网络不可达,应用进程也应该把ENETUNREACH和EHOSTUNREACH作为相同错误对待,因为网络不可达错误被认为已过时。

使用自己编写的daytime客户程序查看建立连接时不同的出错情况,首先指定服务器为本地主机(127.0.0.1),它正在运行daytime服务器程序,观察正常输出:
在这里插入图片描述
为了查看返回的另一种格式,我们指定另外一个主机(HP-UX主机)的IP:
在这里插入图片描述
接着我们指定本地子网(192.168.1/24)上主机ID为100的不存在的IP地址,这样当客户主机发出ARP请求(要求那个不存在的主机响应其硬件地址)时,它将收不到ARP响应:
在这里插入图片描述
我们等connect函数超时后(Solaris 9上约4分钟)才得到该错误。我们的err_sys函数以直观可读的字符串消息显示了ETIMEDOUT错误的含义。

下例指定了一个没有运行daytime服务器程序的主机(其实是一个本地路由器):
在这里插入图片描述
上例中服务器主机立即响应了一个RST分节。

下例中指定一个因特网中不可到达的IP地址,如果我们用tcpdump观察分组情况,会发现6跳远的路由器返回了主机不可达ICMP错误:
在这里插入图片描述
上例中的connect函数也在等待了规定的一段时间后才返回EHOSTUNREACH错误。

connect函数导致TCP状态从CLOSED状态到SYN_SENT状态,若函数返回成功,则再到ESTABLISHED状态。若connect失败,则该套接字不再可用,必须关闭,我们不能再对这样的套接字再次调用connect。

bind函数把一个本地协议地址赋予一个套接字,对于网际协议,协议地址为32bit IPv4地址或128bit IPv6地址与16位TCP或UDP端口号的组合:
在这里插入图片描述
历史上讲述bind函数的手册页面曾说(bind assigns a name to an unnamed socket)bind函数为一个无名的套接字命名,使用name一次让人混淆,因为它具有如foo.bar.com之类域名的含义。bind函数其实与名字没有任何关系,它只是把一个协议地址赋予一个套接字,至于协议地址的含义则取决于协议本身。

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

服务器在启动时绑定它们的众所周知端口号,如果一个TCP客户或服务器未调用bind捆绑一个端口,当调用connect或listen时,内核要为相应的套接字选择一个临时端口。让内核选择临时端口对TCP客户来说是正常的,除非应用需要预留一个端口;然而分配临时端口对于TCP服务器很罕见,因为服务器是通过它们的众所周知端口被大家认识的,但例外是远程过程调用(RPC,Remote Procedure Call)服务器,它们通常由内核为它们的监听套接字选择一个临时端口,该端口随后通过RPC端口映射器注册,客户在connect这些服务器前,需要与端口映射器联系以获取它们的临时端口。

进程可把一个特定IP捆绑到它的套接字上,该IP必须属于其所在主机的网络接口之一。对于TCP客户,这为在该套接字上发送的IP数据报指派了源IP地址;对于TCP服务器,这限定了该套接字只接收那些目的地为这个IP地址的客户连接。但TCP客户通常不把它的IP捆绑到它的套接字上,当连接套接字时,内核将根据所用的外出网络接口选择源IP,而所用的外出接口取决于到达服务器所需的路径。如果服务器没有把IP捆绑到它的套接字上,内核就把客户发送的SYN的目的IP作为服务器的源IP。

bind函数可仅指定IP(非通配地址)或仅指定端口(端口号非0)或都指定或都不指定,效果如下:
在这里插入图片描述
如果指定端口号为0,那么内核在调用bind时选择一个临时端口;如果指定IP地址为通配地址,那么内核将等到套接字已连接(TCP)或已在套接字上发出数据报(UDP)时才选择一个本地IP地址。

对于IPv4,通配地址由常值INADDR_ANY指定,其值一般为0,它告知内核去选择IP地址:

struct sockaddr_in servaddr;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY)    /* wildcard */

上例赋值对IPv4是可行的,因为其IP是一个32位的值,可用一个简单地数字常值表示。但对于IPv6,由于它的地址是存在一个结构中的,且C语言的赋值语句右边不能表示常值结构,因此不能这么做,但可以将其改写为:

serv.sin6_addr = in6addr_any;    /* wildcard */

系统会分配in6addr_any变量并将其初始化为常值IN6ADDR_ANY_INIT,头文件netinet/in.h中含有in6addr_any的extern声明。

INADDR_ANY的值为全0,因此网络字节序和主机字节序是一样的,因此使用htonl并非必须,但头文件netinet/in.h中定义的所有INADDR_常值都是按主机字节序定义的,因此应该对这些常值都调用htonl。

如果让内核为套接字选择一个临时端口,bind函数并不返回其选择的值,实际上,bind函数的第2个参数有const限定词,它无法返回所选的值,只能通过getsockname函数返回协议地址来获得这个临时端口值。

进程捆绑非通配地址到套接字上的常见例子是在为多个组织提供Web服务器的主机上,每个组织有各自的域名,每个域名映射到不同的IP地址,但通常都在同一子网上,可把这些IP地址定义成单个网络接口的别名(如在4.4 BSD上使用ifconfig命令的alias选项来定义,相当于一个接口(网卡)上配置了多个IP地址),这样IP层将接收所有目的IP地址为任何一个别名IP地址的外来数据报,最后为每个组织启动一个HTTP服务器的副本,每个副本仅捆绑相应组织的IP地址。

替换上述方法的另一种技术是运行捆绑通配地址的单个服务器,当一个连接到达时,服务器调用getsockname获取客户的目的IP地址,然后服务器根据目的IP来处理请求。

绑定非通配IP地址的好处是把给定的目的IP分配给特定的服务器进程是由内核而非服务器进程完成的。

要区分一个分组的到达接口和该分组的目的IP地址。在弱端系统模型和强端系统模型中,大多实现选择前者,这意味着一个分组只要其目的IP地址是主机上某个接口的地址即可,它的到达接口地址不一定要是其目的IP地址(这里假设目的主机是多宿主机)。弱端系统模型中,绑定到非通配地址只是根据目的IP地址来确定递送到套接字的数据报,而对于到达接口未做限制,除非主机采用强端系统模型。

bind函数的一个常见返回错误是EADDRINUSE,即地址已使用。

listen函数仅由TCP服务器调用,作用为:
1.socket函数创建一个套接字时,默认它是一个主动套接字,即它是一个将调用connect发起连接的客户套接字,listen函数将一个未连接的套接字转换成一个被动套接字,指示内核应接受指向该套接字的连接请求。调用listen导致套接字从CLOSED转换成LISTEN状态。
2.该函数的第二个参数backlog规定了内核应该为相应套接字排队的最大连接个数:
在这里插入图片描述
listen函数通常在调用socket和bind后,accept前调用。

内核为每个监听套接字维护两个队列:
1.未完成连接队列:存放“某个客户发出SYN并到达服务器,而服务器在等待完成TCP三次握手过程”的连接。这些套接字处于SYN_RCVD状态。
2.已完成连接队列:存放已完成TCP三次握手的连接。这些套接字处于ESTABLISHED状态。
在这里插入图片描述
在未完成连接队列中创建一项时,监听套接字的参数(连接本端的端口、ip等)就被复制到即将建立的连接中。连接的创建是自动的,无需服务器进程插手。用上图中两个队列建立连接时所交换的分组:
在这里插入图片描述
当来自客户的SYN到达时,TCP在未完成连接队列中创建一个新项,然后响应三路握手的第二个分节(服务器的SYN响应+对客户SYN的ACK),这一项一直保留在未完成连接队列中,直到三路握手的第3个分节(客户对服务器SYN的ACK)到达或这一项超时为值(源自伯克利的实现为这些未完成连接的项设置的超时值为75s)。三次握手完成时,位于未完成连接队列的该项被插入到已完成连接队列的队尾。当进程调用accept时,已完成连接队列中的队头项返回给该进程,如果该队列为空,则进程将进入睡眠,直到TCP往该队列中放入一项才唤醒它。

listen函数的backlog参数曾被规定为这两个队列总和的最大值。

backlog的含义从未有过正式定义,4.2 BSD的手册页面说它是由the maximum length the queue of pending connection may grow to(未处理连接构成的队列可能增长到的最大长度),许多手册页面甚至POSIX规范也逐字复制该定义,然而该定义并未解释未处理连接是处于SYN_RCVD状态的连接,还是处于ESTABLISHED状态的连接,亦或两者皆可。

源自Berkeley的实现给backlog增设了一个模糊因子,把该值乘以1.5得到未处理队列最大长度,例如,指定为5的backlog参数值实际允许最多有8项在排队。增设该模糊因子的理由已无从考证,但如果我们把backlog看成是内核能为某套接字排队的最大已完成连接数,那么增加模糊因子的理由就是把队列中的未完成连接也算在内。

不要把backlog参数设为0,因为不同实现对此有不同解释,如果不想让客户连接到监听套接字上,那就关掉该套接字。

在三路握手正常完成的前提下(没有丢失分节,从而没有重传),未完成连接队列中任何一项的留存时间为一个RTT,而RTT的值取决于特定的客户与服务器,TCPv3指出,对于一个Web服务器,许多客户与单个服务器之间的中值RTT为187ms(由于出现一些大值可能显著扭曲均值,对于该统计量通常使用中值)。

历史上,样例代码中的backlog参数值总是5,因为这是4.2 BSD支持的最大值,这个值在20世纪80年代是足够的,当时繁忙的服务器一天也就处理几百个连接。但随着万维网(WWW,World Wide Web)的发展,繁忙的服务器一天要处理几百万个连接,这个值就不够了。繁忙的HTTP服务器必须指定一个大得多的backlog值,且较新的内核必须支持较大的backlog值。当前许多系统允许管理员修改backlog参数的最大值。

既然backlog值为5往往是不够的,进程应指定多大的backlog呢?当今的HTTP服务器指定了一个较大的值,但如果这个值再源码中是一个常值,那么增长其大小需要重新编译服务器程序。另一个方法是设定一个默认值,但允许通过命令行选项或环境变量覆盖该默认值。指定一个比内核能支持的值还要打的backlog也是可接受的,因为内核应该悄然把所指定的偏大值截成自身支持的最大值,而不返回错误。通过修改listen函数的包裹函数是比较简单的解决方式,我们允许环境变量LISTENQ覆盖由调用者指定的值:

void Listen(int fd, int backlog) {
    char *ptr;
    
    /* can override 2nd argument with environment variable */
    if ((ptr = getenv("LISTENQ")) != NULL) {
        backlog = atoi(ptr);
    }
    
    if (listen(fd, backlog) < 0) {
        err_sys("listen error");
    }
}

未完成连接队列和已完成连接队列中哪个队列中项多是不确定的。

一个客户的SYN到达时,如果队列是满的,TCP就忽略该分节,即不发送RST。这是因为这种情况是暂时的,客户TCP将重发SYN,我们期望不久这些队列中就有可用空间。如果服务器TCP响应以一个RST,客户的connect调用会返回一个错误,强制应用进程处理该错误,而不是让TCP正常重传机制来处理。此外,客户无法区分响应SYN段的RST究竟意味着该端口没有服务器监听还是该端口有服务器监听,但它的队列满了

有些实现在队满时发送RST,这种做法是错误的,我们最好忽略其存在的可能性,除非客户明确要求与这样的服务器交互。处理这种情况的额外代码会降低客户程序的健壮性,并且当正常发送RST(端口确实没有服务器在监听)时,也会增加网络负载(发送端分不清是没有服务器还是服务器队列满,还会重试)。

当服务器调用accept之前,三路握手完成后,到达的数据由服务器TCP排队,且最大数据量为该已连接套接字的接收缓冲区大小。

各种操作系统下,backlog参数取不同值时,两队列中的连接数目,从下图可见对backlog的意义解释是多样的:
在这里插入图片描述
AIX和Mac OS用传统的Berkeley算法,Solaris也非常接近该算法,FreeBSD则是backlog值加1。

历史上曾把backlog值指定为两个队列之和的最大值。在1996年间因特网受到一种称为SYN泛滥的新型攻击,黑客编写了一个以高速率给受害主机发送SYN的程序,用以填满一个或多个TCP端口的未完成连接队列,而且该程序将每个SYN的源IP地址都置为随机数(称为IP欺骗),这样服务器的SYN/ACK就发往不知道什么地方,同时防止受攻击服务器获取黑客的真实IP地址。这样,通过伪造的SYN填满未完成连接队列,使合法的SYN排不上队,导致针对合法客户的服务被拒绝。因此backlog更好的含义是指定某个给定套接字上内核为之排队的最大已完成连接数,如果一个系统实现了这样的解释(如BSD/OS 3.0),那么应用程序就无须因为服务器进程需要处理大量客户请求或为了提供对SYN泛滥的防护而指定一个巨大的backlog值了,内核会处理大量的未完成连接,不管它们是来自合法客户还是黑客。但即使在这样的解释下,传统为5的backlog参数值不够大的情形依然发生。

accept函数由TCP服务器调用,用于从已完成连接队列的队头返回下一个已完成连接,如果已完成连接队列为空,则进程会进入睡眠(假定套接字为默认的阻塞方式):
在这里插入图片描述
参数cliaddr和addrlen用来返回已连接的客户进程的协议地址,参数addrlen是值结果参数,调用accept前,参数addrlen指向的整数值为参数cliaddr所指套接字地址结构的长度,返回时,该整数为由内核存放在该套接字地址结构内的确切字节数。

accept调用成功时,返回值是由内核自动生成的一个全新描述符,代表与所返回客户的TCP连接。讨论accept函数时,称sockfd参数为监听套接字描述符,accept的返回值被称为已连接套接字描述符。一个服务器通常只创建一个监听套接字,它在该服务器的生命期内一直存在,内核为每个由服务器进程accept的客户连接创建一个已连接套接字,当服务器完成对某客户的服务时,相应的已连接套接字就被关闭。

accept函数最多返回3个值,一个既可能是新套接字描述符也可能是出错指示的整数、客户进程的协议地址(由cliaddr参数指针所指)、该地址的大小(由addrlen参数指针所指)。如果对返回的客户协议地址不感兴趣,可将cliaddr和addrlen参数置为空指针。

现在我们修改daytime服务器程序代码,以显示客户的IP和端口号:

#include "unp.h"
#include <time.h>

int main(int argc, char **argv) {
    int listenfd, connfd;
    socklen_t len;    // 新定义变量,它将成为一个值-结果变量
    struct sockaddr_in servaddr, cliaddr;    // 新定义变量cliaddr,它将存放客户的协议地址
    char buff[MAXLINE];
    time_t ticks;
    
    listenfd = Socket(AF_INET, SOCK_STREAM, 0);
    
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(13);    /* daytime server */
    
    Bind(listenfd, (SA *)&servaddr, sizeof(servaddr));
    
    Listen(listenfd, LISTENQ);
    
    for (; ; ) {
        len = sizeof(cliaddr);
        connfd = Accept(listenfd, (SA *)&cliaddr, &len);
        printf("connection from %s, port %d\n", 
            Inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
            ntohs(cliaddr.sin_port));
        
        ticks = time(NULL);
        snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));    // 最少打印24个字符
        Write(connfd, buff, strlen(buff));
        
        Close(connfd);
    }
}

运行以上服务器,之后在同一主机上运行两次客户程序,一次的目的IP为环回地址,一次是服务器自身的IP地址:
在这里插入图片描述
下面是相应的服务器输出:
在这里插入图片描述
可见客户IP是有变化的。我们获取时间的客户程序没有调用bind,这样的客户由内核将所用外出接口选定为源IP地址,第一次内核把源IP地址设为环回地址,第二次内核把源IP设为以太网接口的IP地址。我们还可以看到Solaris内核选择的临时端口号先是43388,后是43389。

上图可见,运行服务器脚本的shell提示符变为井号,它是超级用户的常用提示符,该服务器必须以超级用户特权运行,以便绑定保留的13号端口,如果没有超级用户特权,bind调用将失败:
在这里插入图片描述
Unix的fork函数(包括有些系统可能提供的它的变体)是Unix中派生新函数的唯一方法:
在这里插入图片描述
fork函数调用一次,返回两次,它在调用进程(父进程)中返回一次,返回值是新派生的进程(子进程)的进程ID号;在子进程中又返回一次,返回值为0。因此返回值本身告知当前进程是子进程还是父进程。

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

父进程中调用fork前打开的所有描述符在fork函数返回后与子进程共享。网络服务器利用了这个特性,父进程调用accept后调用fork,所接受的已连接套接字随后在父进程和子进程之间共享。通常,子进程会接着读写这个已连接套接字,父进程则关闭这个已连接套接字。

fork函数的两个典型用法:
1.一个进程创建一个自身的副本,这样每个副本都可以在另一个副本执行其他任务的同时处理各自的某个操作。这是网络服务器的典型用法。
2.一个进程想执行另一个程序,该进程首先调用fork创建一个自身的副本,然后其中一个副本(通常为子进程)调用exec把自身替换成新程序。这是shell程序的典型用法。

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

较老的手册称新程序为新进程(new process),这是错误的,因为并没有创建新的进程。

6个exec函数之间的区别在于:
1.待执行的程序文件是由文件名(filename)还是路径名(pathname)指定。
2.新程序的参数是一一列出还是由一个指针数组来引用。
3.把调用进程的环境传递给新程序还是给新程序指定新的环境。
在这里插入图片描述
在这里插入图片描述
以上函数只有在出错时才返回到调用者,否则,控制将被传递给新程序的起始点,通常是main函数。

以上6个函数之间的关系,一般来说,只有execve函数是内核中的系统调用,其他5个函数都是调用execve的库函数:
在这里插入图片描述
6个函数的区别:
1.上图中上面那行的3个函数把新程序的每个参数字符串指定成一个独立参数,并以一个空指针结束可变数量的这些参数。下面那行的3个函数都有一个argv数组参数,其中含有指向新程序各个参数字符串的所有指针,这个数组中含有一个用于指定其末尾的空指针。
2.上图中左侧两个函数指定了filename参数,它们会根据当前使用的PATH环境变量把文件名参数转换为一个路径名,但如果这两个函数的filename参数中含有一个斜杠/,就不再使用PATH环境变量,而是将filename看做一个路径名。右两列的4个函数指定一个路径名。
3.左边两列4个函数没有环境指针参数,它们使用外部变量environ的当前值来构造一个传递给新程序的环境列表。右边两列函数显式指定一个环境列表,其envp参数指针指向的指针数组必须以一个空指针结束。

进程在调用exec前打开的描述符跨exec通常继续保持打开。除非该默认行为被fcntl函数的FD_CLOEXEC描述符标志禁止掉。

前面给出代码的daytime服务器是一个迭代服务器,对于像获取时间这样的简单服务器来说是足够的,但当服务一个客户请求可能花费较长时间时,我们不希望整个服务器被单个客户长期占用,而是希望同时服务多个客户。Unix中编写并发服务器最简单的方法是fork一个子进程来服务每个客户。以下是一个典型的并发服务器的轮廓:

pid_t pid;
int listenfd, connfd;

listenfd = Socket( ... );
Bind(listenfd, ...);    /* fill in sockaddr_in{} with server's well-known port */
Listen(listenfd, LISTENQ);

for (; ; ) {
    connfd = Accept(listenfd, ...);    /* probably blocks */
    if ((pid = Fork()) == 0) {
        Close(listenfd);    /* child closes listening socket */
        doit(connfd);    /* process the request */
        Close(connfd);    /* done with this client */
        exit(0);    /* child terminates */
    }
    Close(connfd);    /* parent closes connected socket */
}

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

以上程序我们假设函数doit执行服务客户所需的所有操作,当该函数返回时,我们在子进程中显式地关闭已连接套接字,这一点并非必需,因为下一个语句就是调用exit,而进程终止处理的部分工作就是关闭所有由内核打开的描述符。是否显式调用close只和个人编程风格有关。

对一个TCP套接字调用close会导致发送一个FIN,随后是正常的TCP连接终止序列,但上例代码中,父进程对connfd调用close没有终止它与客户的连接,这是因为每个文件或套接字都有一个引用计数,引用计数在文件表项中维护,含义是当前打开着的引用该文件或套接字的描述符个数。上例中,socket函数返回后与listenfd关联的文件表项的引用计数为1,accpet函数返回后与connfd关联的文件表项的引用计数也为1,但当fork函数返回后,这两个描述符就在父进程与子进程间共享(即被复制),因此与这两个套接字相关联的文件表项各自的访问计数值均为2,因此,当父进程关闭connfd时,它只是把相应引用计数值从2减为1,该套接字真正的清理和资源释放要等到其引用计数值到0时才发生,即会在稍后子进程也关闭connfd时发生。

下图是服务器阻塞于accept调用时,来自客户的连接请求到达时客户和服务器的状态:
在这里插入图片描述
从accept函数返回后,我们就有下图所示状态,此时连接已被内核接受,新的套接字connfd被创建,这是一个已连接套接字,可由此跨连接读写数据:
在这里插入图片描述
并发服务器下一步调用fork,下图是从fork函数返回后的状态:
在这里插入图片描述
如上图,此时listenfd和connfd两个描述符都在父进程和子进程之间共享(被复制)。下一步是父进程关闭已连接套接字,子进程关闭监听套接字:
在这里插入图片描述
上图是这两个套接字所期望的最终状态,子进程处理与客户的连接,父进程在监听套接字上再次调用accept处理下一个客户连接。

通常的Unix close函数也用来关闭套接字,并终止TCP连接:
在这里插入图片描述
close一个TCP套接字的默认行为是把该套接字标记成已关闭,然后立即返回到调用进程。该套接字描述符不能再由调用进程使用,即它不能再作为read和write函数的第一个参数。TCP将尝试发送正排队等待发送到对端的数据,发送完毕后发生的是正常的TCP连接终止序列。

如果我们想在某个TCP连接上发送一个FIN(而不是调用close减少引用计数),可以改用shutdown函数。

如果父进程对每个由accept函数返回的已连接套接字都不调用close,父进程最终将耗尽可用描述符,因为进程可拥有的打开着的描述符数量通常是有限制的,且没有一个客户连接会被终止,当子进程关闭已连接套接字时,它的引用计数由2减为1且保持为1,因为父进程永不关闭任何已连接套接字,这将妨碍TCP连接终止序列的发生,导致连接一直打开着。

获取与某个套接字关联的本地协议地址的函数(getsockname函数),或获取与某个套接字关联的外地协议地址的函数(getpeername函数):
在这里插入图片描述
以上两个函数的最后一个参数都是值-结果参数,这两个函数都会装填由localaddr或peeraddr指针所指的套接字地址结构。

需要以上两个函数的理由:
1.在一个没有调用bind的TCP客户上,connect函数返回成功后,getsockname可用于返回由内核赋予该连接的本地IP地址和本地端口号。
2.在以端口号0调用bind(告知内核去选择本地端口号)后,getsockname用于返回由内核赋予的本地端口号。
3.getsockname函数可用于获取某个套接字的地址族。
4.在一个以通配IP地址调用bind的TCP服务器上,与某个客户的连接一旦建立(accept成功返回),getsockname函数可用于返回由内核赋予该连接的本地IP地址(此时要用的是已连接套接字的描述符而非监听套接字的描述符)。
5.当一个服务器是由调用过accept函数的某个进程通过调用exec执行程序时,它获取客户身份的唯一途径就是调用getpeername。当inetd fork并exec某个TCP服务器时就是如此情形,如下图,inetd调用accept返回两个值(图中左上方框):已连接套接字描述符connfd(函数返回值)和客户的IP和端口号(图中小方框表示,代表一个网际套接字地址结构),inetd随后调用fork,派生出inetd的一个子进程,既然子进程起始于父进程的内存映像的一个副本,父进程中的套接字地址结构在子进程中也可用,已连接套接字描述符也是如此(父子进程共享描述符)。但当子进程调用exec执行真正的服务器程序(如Telnet服务器程序)时,子进程的内存映像被替换成新的Telnet服务器的程序文件,此时包含对端地址的那个套接字地址结构就此丢失,但已连接套接字描述符跨exec函数继续保持开放。Telnet服务器首先调用的函数之一便是getpeername,用于获取客户IP地址和端口号。
在这里插入图片描述
上图中,Telnet服务器启动后需要先获取connfd的值,有两个常用方法:
1.调用exec的进程把这个描述符格式化成一个字符串,再把它作为一个命令行参数传递给新程序。
2.约定在调用exec前,总是把某个特定描述符置为已连接套接字的描述符。
inetd采用的第2种方法,它总是把描述符0、1、2置为已连接套接字的描述符。

以下函数返回某个套接字的地址族:

#include "unp.h"

int sockfd_to_family(int sockfd) {
    struct sockaddr_storage ss;    // 既然不知道要分配的套接字地址结构的类型,于是采用sockaddr_storage这个通用结构,它能承载系统支持的任何套接字地址结构
    socklen_t len;
    
    len = sizeof(ss);
    // 既然POSIX允许对未绑定的套接字调用getsockname,该函数应该适合任何已打开的套接字描述符
    if (getsockname(sockfd, (SA *)&ss, &len) < 0) {
        return -1;
    }
    return ss.ss_family;
}

大多TCP服务器是并发的,但大多数UDP服务器是迭代的。

我们说INADDR_常值是主机字节序的,但INADDR_ANY的各位全0和INADDR_NONE的各位全1是看不出来的,其他常值如D类多播地址INADDR_MAX_LOCAL_GROUP(224.0.0.255)就可以看出来是按主机字节序定义的了。

getsockname函数的值-结果参数(len)必须在调用前初始化为由第二个指针参数所指变量的大小。涉及值-结果参数的最常见编程错误就是忘记了这样的初始化。

如果没有对套接字描述符调用listen就传给accept函数,accept函数会返回EINVAL,因为它的第一个参数不是一个监听套接字描述符。

如果没有对套接字描述符调用bind就调用listen,则listen函数会赋予监听套接字一个临时端口。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值