板凳---------unix网络编程卷1:第四章 基本 TCP 套接字编程

4.1 概述

本章中只考虑使用fork实施的每客户单进程模型
服务器首先启动,稍后某个时刻客户启动,它试图连接到服务器。我们假设客户给服务器发送一个请求,服务器处理该请求,并且给客户发回一个响应。这个过程一直持续下去,直到客户关闭连接的客户端,从而给服务器发送一个EOF(文件结束)通知为止。服务器接着也关闭连接的服务器端,然后结束运行或者等待新的客户连接。

4.2 socket 函数

为了执行网络I/O,一个进程必须做的第一件事情就是调用socket函数,指定期望的通信
协议类型(使用IPv4的TCP、使用IPv6的UDP、Unix域字节流协议等)。
#include <sys/socket.h>
int socket(int family, int type, int protocol);
返回:若成功则为非负描述符,若出错则为-1
其中family参数指明协议族,常值。该参数也往往被称为协议域。
type参数指明套接字类型,常值。
protocol参数应设为某个协议类型常值,或者设为0,以选择所给定family和type组合的系统默认值。AF_NS(Xerox NS协 议,常称为XNS)和AF_ISO(OSI协议)都实现了对SOCK_SEQPACKET这个type参数值的支持,在SCTP中的使用。
套接字类型SOCK_PACKET,支持对数据链路的访问。
密钥套接字AF_KEY,用于支持基于加密的安全性。
路由套接字(AF_ROUTE)是内核中路由表的接口这种方式。

在这里插入图片描述
在这里插入图片描述

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

4.3 connect 函数

TCP客户用connect函数来建立与TCP服务器的连接。
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
返回:若成功则为0,若出错则为-1
sockfd是由socket函数返回的套接字描述符,第二个、第三个参数分别是一个指向套接字
地址结构的指针和该结构的大小。套接字地址结构必须含有服务器的IP地址和端口号。
客户在调用函数connect前不必非得调用bind函数,因为如果需要的话,内核会确定源IP地址,并选择一个临时端口作为源端口。
如果是TCP套接字,调用connect函数将激发TCP的三路握手过程,而且仅在连接建立成功或出错时才返回,其中出错返回可能有以下几种情况。
(1) 若TCP客户没有收到SYN分节的响应,则返回ETIMEDOUT错误。举例来说,调用connect
函数时,内核发送一个SYN,若无响应则等待6s后再发送一个,若仍无响应则等待24s
后再发送一个。若总共等了75s后仍未收到响应则返回本错误。
(2) 若对客户的SYN的响应是RST(表示复位),则表明该服务器主机在我们指定的端口上
没有进程在等待与之连接。这是一种硬错误(hard error),客户一接收到RST就马上返回ECONNREFUSED错误。 RST是TCP在发生错误时发送的一种TCP分节。产生RST的三个条件是:目的地为某端口的SYN到达,然而该端口上没有正在监听的服务器;TCP想取消一个已有连接;TCP接收到一个根本不存在的连接上的分节。
(3) 若客户发出的SYN在中间的某个路由器上引发了一个“destination unreachable”(目的地不可达)ICMP错误,则认为是一种软错误(soft error)。客户主机内核保存该消息,并按第一 种情况中所述的时间间隔继续发送SYN。若在某个规定的时间(规定75s)后仍未收到响
应,则把保存的消息(即ICMP错误)作为EHOSTUNREACH或ENETUNREACH错误返回给进程。以下两种情形也是有可能的:
一是按照本地系统的转发表,根本没有到达远程系统的路径;
二是connect调用根本不等待就返回。
按照TCP状态转换图,connect函数导致当前套接字从CLOSED状态(该套接字自从由socket函数创建以来一直所处的状态)转移到SYN_SENT状态,若成功则再转移到STABLISHED状态。若connect失败则该套接字不再可用,必须关闭,我们不能对这样的套接字再次调用connect函数。

4.4 bind 函数

bind函数把一个本地协议地址赋予一个套接字。对于网际网协议,协议地址是32位的IPv4
地址或128位的IPv6地址与16位的TCP或UDP端口号的组合。
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
返回:若成功则为0,若出错则为-1
第二个参数是一个指向特定于协议的地址结构的指针,第三个参数是该地址结构的长度。
对于TCP,调用bind函数可以指定一个端口号,或指定一个IP地址,也可以两者都指定,还可以都不指定。
服务器在启动时绑定众所周知端口。
这个规则的例外是远程过程调用(Remote Procedure Call,RPC)服务器。它们通常就由内核为它们的监听套接字选择一个临时端口,而该端口随后通过RPC端口映射器进行注册。客户在connect这些服务器之前,必须与端口映射器联系以获取它们的临时端口。这种情况也适用于使用UDP的RPC服务器。
进程可以把一个特定的IP地址捆绑到它的套接字上,不过这个IP地址必须属于其所在主机的网络接口之一。
捆绑(binding)操作涉及三个对象:套接字(在XTI API中为端点)、地址及端口。其中套接字是捆绑的主体,地址和端口是捆绑在套接字上的客体。
(1)“捆绑地址A和/或端口P到套接字S”。同义说法还有:“把地址A和/或端口P捆绑到套接字S”,“给套接字S捆绑地址A和/或端口P”,等等。
(2)“跟端口P(地址A)一块捆绑地址A(端口P)”。
绑定(bound)表示捆绑成功后的状态,它的各种说法如下:
(1)“绑定地址A和/或端口P的套接字”。
(2)“套接字S上绑定的地址或端口”。
(3)“已绑定的地址或端口”。也就是说该地址或端口已为某个套接字所用。
(4)“跟端口P(地址A)一块绑定的地址(端口)”。
(5)“套接字S已绑定”。相反的说法是“套接字S未绑定”。

指定端口号为0,那么内核就在bind被调用时选择一个临时端口。
IPv4来说,通配地址由常值INADDR_ANY来指定,其值一般为0。
struct sockaddr_in servaddr;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); /* wildcard /
IPv4,IP地址是一个32位的值,
对于IPv6,128位的IPv6地址是存放在一个结构中的。
struct sockaddr_in6 serv;
serv.sin6_addr = in6addr_any; /
wildcard */
无论是网络字节序还是主机字节序,INADDR_ANY的值(为0)都一样,因此使用htonl并
非必需。不过既然头文件<netinet/in.h>中定义的所有INADDR_常值都是按照主机字节序定义
的,我们应该对任何这些常值都使用htonl。

4.5 listen 函数

listen函数仅由TCP服务器调用,它做两件事情。
(1) 当socket函数创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个
将调用connect发起连接的客户套接字。listen函数把一个未连接的套接字转换成一个被动套
接字,指示内核应接受指向该套接字的连接请求。根据TCP状态转换图,调用listen导致套接字从CLOSED状态转换到LISTEN状态。
(2) 本函数的第二个参数规定了内核应该为相应套接字排队的最大连接个数。
#include <sys/socket.h>
int listen(int sockfd, int backlog);
返回:若成功则为0,若出错则为-1
必须认识到内核为任何一个给定的监听套接字维护两个队列:
(1) 未完成连接队列(incomplete connection queue),每个这样的SYN分节对应其中一项:
已由某个客户发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程。这些套接
字处于SYN_RCVD状态。
(2) 已完成连接队列(completed connection queue),每个已完成TCP三路握手过程的客户对应其中一项。这些套接字处于ESTABLISHED状态
在这里插入图片描述

当来自客户的SYN到达时,TCP在未完成连接队列中创建一个新项,然后响应以三路握手
的第二个分节:服务器的SYN响应,其中捎带对客户SYN的ACK。这一项一直保留在未完成连接队列中,直到三路握手的第三个分节(客户对服务器SYN的ACK)到达或者该项超时为止。(超时值为75 s。)如果三路握手正常完成,该项就从未完成连接队列移到已完成连接队列的队尾。当进程调用accept时,已完成连接队列中的队头项将返回给进程,或者如果该队列为空,那么进程将被置于休眠状态,直到TCP在该队列中放入一项才唤醒它。
在这里插入图片描述

listen函数的backlog参数曾被规定为这两个队列总和的最大值。
backlog的含义从未有过正式的定义。定义的是:“the maximum length the queue of pending connections may grow to”(由未处理连接构成的队列可能增长到的最大长度)。该定义并未解释未处理连接是处于SYN_RCVD状态的连接,还是尚未由进程接受的处于ESTABLISHED状态的连接,或两者皆可。
backlog增设了一个模糊因子(fudge factor):把它乘以1.5得到未处理队列最大长度。举例来说,通常指定为5的backlog值实际上允许最多有8项在排队。
不要把backlog定义为0,因为不同的实现对此有不同的解释。如果你不想让任
何客户连接到你的监听套接字上,那就关掉该监听套接字。
在三路握手正常完成的前提下,未完成连接队列中的任何一项在其中的存留时间就是一个RTT,而RTT的值取决于特定的客户与服务器。对于一个Web服务器,许多客户与单个服务器之间的中值RTT为187ms。
如果这个指定值在源代码中是一个常值,那么增长其大小需要重新编译服务器程序。另一个方法是设定一个默认值,允许通过命令行选项或环境变量覆写该默认值。指定一个比内核能够支持的值还要大的backlog也是可接受的,因为内核应该悄然把所指定的偏大值截成自身支持的最大值。
在三路握手完成之后,但在服务器调用accept之前到达的数据应由服务器TCP排队,最
大数据量为相应已连接套接字的接收缓冲区大小。

4.6 accept 函数

accept函数由TCP服务器调用,用于从已完成连接队列队头返回下一个已完成连接 。如果已完成连接队列为空,那么进程被置于休眠状态(假定套接字为默认的阻塞方式)。
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t addrlen);
返回:若成功则为非负描述符,若出错则为-1
参数cliaddr和addrlen用来返回已连接的对端进程(客户)的协议地址。addrlen是值—结果
参数:调用前,我们将由
addrlen所引用的整数值置为由cliaddr所指的套接字地址结构
的长度,返回时,该整数值即为由内核存放在该套接字地址结构内的确切字节数。
如果accept成功,那么其返回值是由内核自动生成的一个全新描述符,代表与所返回客户
的TCP连接。第一个参数为监听套接字(listening socket)描述符(由socket创建,随后用作bind和listen的第一个参数的描述符),称它的返回值为已连接套接字(connected socket)描述符。区分这两个套接字非常重要。一个服务器通常仅仅创建一个监听套接字,它在该服务器的生命期内一直存在。内核为每个由服务器进程接受的客户连接创建一个已连接套接字。当服务器完成对某个给定客户的服务时,相应的已连接套接字就被关闭。
本函数最多返回三个值:一个既可能是新套接字描述符也可能是出错指示的整数、客户进
程的协议地址(由cliaddr指针所指)以及该地址的大小(由addrlen指针所指)。如果我们对返回客户协议地址不感兴趣,那么可以把cliaddr和addrlen均置为空指针。
服务器是一个迭代服务器(iterative server)。对于像时间获取这样的简单服务器来说,这就够了。

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

int
main(int argc, char **argv)
{
	int					listenfd, connfd;
	socklen_t			len;
	struct sockaddr_in	servaddr, 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));
        Write(connfd, buff, strlen(buff));

		Close(connfd);
	}
}

(base) wannian07@wannian07-PC:~/Unix-Network-Programming/intro$ make daytimetcpsrv1
gcc -I…/lib -g -O2 -D_REENTRANT -Wall -c -o daytimetcpsrv1.o daytimetcpsrv1.c
gcc -I…/lib -g -O2 -D_REENTRANT -Wall -o daytimetcpsrv1 daytimetcpsrv1.o …/libunp.a -lpthread
(base) wannian07@wannian07-PC:~/Unix-Network-Programming/intro$ sudo ./daytimetcpsrv1 &
[1] 4052

4.7 fork 和 exec 函数

#include <unistd.h>
pid_t fork(void);
返回:在子进程中为0,在父进程中为子进程ID,若出错则为-1
调用进程(称为父进程)中返回一次,返回值是新派生进程(称为子进程)的进程ID号;
在子进程又返回一次,返回值为0。因此,返回值本身告知当前进程是子进程还是父进程。
fork在子进程返回0而不是父进程的进程ID的原因在于:任何子进程只有一个父进程,而
且子进程总是可以通过调用getppid取得父进程的进程ID。相反,父进程可以有许多子进程,
而且无法获取各个子进程的进程ID。如果父进程想要跟踪所有子进程的进程ID,那么它必须记
录每次调用fork的返回值。
父进程中调用fork之前打开的所有描述符在fork返回之后由子进程分享。父进程调用accept之后调用fork。所接受的已连接套接字随后就在父进程与子进程之间共享。通常情况下,子进程接着读写这个已连接套接字,父进程则关闭这个已连接套接字。
fork有两个典型用法。
(1) 一个进程创建一个自身的副本,这样每个副本都可以在另一个副本执行其他任务的同时
处理各自的某个操作。这是网络服务器的典型用法。
(2) 一个进程想要执行另一个程序。既然创建新进程的唯一办法是调用fork,该进程于是
首先调用fork创建一个自身的副本,然后其中一个副本调用exec把自身替换成新的程序。这是诸如shell之类程序的典型用法。
Unix执行的唯一方法是:由一个现有进程调用六个exec函数中的某一个。exec把当前进程映像替换成新的程序文件,而且该新程序通常从main函数开始执行。进程ID并不改变。我们称调用exec的进程为调用进程(calling process),称新执行的程序为新程序(new program)。
#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
一般来说,只有execve是内核中的系统调用,其他5个都是调用execve的库函数。
在这里插入图片描述

4.8 并发服务器

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

pid_t pid; 
int listenfd, connfd; 
listenfd = Socket( ... ); 
/* fill in sockaddr_in{} with server’s well-known port */ 
Bind(listenfd, ... ); 
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 */ 
} 

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

4.9 close 函数

通常的Unix close函数也用来关闭套接字,并终止TCP连接。
#include <unistd.h>
int close(int sockfd);
返回:若成功则为0,若出错则为-1
close一个TCP套接字的默认行为是把该套接字标记成已关闭,然后立即返回到调用进程。
该套接字描述符不能再由调用进程使用,也就是说它不能再作为read或write的第一个参数。
然而TCP将尝试发送已排队等待发送到对端的任何数据,发送完毕后发生的是正常的TCP连接
终止序列。
描述符引用计数
并发服务器中父进程关闭已连接套接字只是导致相应描述符的引用计数值减1。既然引用计数值仍大于0,这个close调用并不引发TCP的四分组连接终止序列。对于父进程与子进程共享已连接套接字的并发服务器来说,这正是所期望的。
首先,父进程最终将耗尽可用描述符,因为任何进程在任何时刻可拥有的打开着的描述符数通常是有限制的。不过更重要的是,没有一个客户连接会被终止。当子进程关闭已连接套接字时,它的引用计数值将由2递减为1且保持为1,因为父进程永不关闭任何已连接套接字。这将妨碍TCP连接终止序列的发生,导致连接一直打开着。

4.10 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指针所指的套接字地址结构。
理由如下所述。
在一个没有调用bind的TCP客户上,connect成功返回后,getsockname用于返回由内
核赋予该连接的本地IP地址和本地端口号。
在以端口号0调用bind后,getsockname用于返回由内核赋予的本地端口号。
getsockname可用于获取某个套接字的地址族。
在一个以通配IP地址调用bind的TCP服务器上,与某个客户的连接一旦建立(accept成功返回),getsockname就可以用于返回由内核赋予该连接的本地IP地址。在这样的调用中,套接字描述符参数必须是已连接套接字的描述符,而不是监听套接字的描述符。
当一个服务器是由调用过accept的某个进程通过调用exec执行程序时,它能够获取客
户身份的唯一途径便是调用getpeername。inetd fork并exec某个TCP服务器程序时就是如此情形。inetd调用accept(左上方方框)返回两个值:
已连接套接字描述符connfd,这是函数的返回值;客户的IP地址及端口号,如图中标有
“对端地址”的小方框所示(代表一个网际网套接字地址结构)。inetd随后调用fork,
派生出inetd的一个子进程。既然子进程起始于父进程的内存映像的一个副本,父进程
中的那个套接字地址结构在子进程中也可用,那个已连接套接字描述符也是如此(因为
描述符在父子进程之间是共享的)。然而当子进程调用exec执行真正的服务器程序(譬
如说Telnet服务器程序)时,子进程的内存映像被替换成新的Telnet服务器的程序文件(也
就是说包含对端地址的那个套接字地址结构就此丢失),不过那个已连接套接字描述符
跨exec继续保持开放。Telnet服务器首先调用的函数之一便是getpeername,用于获取
客户的IP地址和端口号。
在这里插入图片描述

Telnet服务器必须在启动之后获取connfd的值。获取该值有两个常用方法。
第一种方法是,调用exec的进程可以把这个描述符号格式化成一个字符串,再把它作为一个命令行参数传递给新程序。
第二种方法是,约定在调用exec之前,总是把某个特定描述符置为所接受的已连接套接字的描述符。
inetd采用的是第二种方法,它总是把描述符0、1和2置为所接受的已连接套接字的描述符。
获取套接字的地址族

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

相关推荐

最近更新

  1. MySQL

    MySQL

    2024-07-10 23:24:02      0 阅读
  2. C++中的atomic:原子

    2024-07-10 23:24:02       0 阅读
  3. Nacos 面试题及答案整理,最新面试题

    2024-07-10 23:24:02       0 阅读
  4. 【Unity】RPG2D龙城纷争(十五)特殊加成型要诀

    2024-07-10 23:24:02       0 阅读
  5. ETCD介绍以及Go语言中使用ETCD详解

    2024-07-10 23:24:02       0 阅读
  6. C语言:再探C语言指针

    2024-07-10 23:24:02       0 阅读

热门阅读

  1. xml_woarchive undefined symbol

    2024-07-10 23:24:02       6 阅读
  2. 基于深度学习的安全帽检测

    2024-07-10 23:24:02       8 阅读
  3. swift获取app网络和本地网络权限

    2024-07-10 23:24:02       9 阅读
  4. C语言获取当前时间

    2024-07-10 23:24:02       11 阅读
  5. Unity3D项目中如何正确使用Lua详解

    2024-07-10 23:24:02       8 阅读
  6. WPF更新UI线程实现进度条功能

    2024-07-10 23:24:02       11 阅读
  7. mysql 导出导入 数据库

    2024-07-10 23:24:02       9 阅读
  8. python-django-LlamaIndex 精简版

    2024-07-10 23:24:02       5 阅读
  9. 深入Django(五)

    2024-07-10 23:24:02       10 阅读
  10. Django之登录权限系统

    2024-07-10 23:24:02       7 阅读
  11. LeetCode 35, 242, 994

    2024-07-10 23:24:02       9 阅读