• 【UNIX网络编程】基本TCP套接字编程


    本章讲解的并发服务器是使用fork实施的每客户单进程模型

    以下是基本TCP客户/服务器程序的套接字函数(发生的一些典型事件的时间表):

    TCP状态转换图:

    1、socket函数: 

    #include <sys/socket.h>
    int socket(int family, int type, int protocol);    // 返回非负描述符 或 -1(出错)
    

    family:协议族 (AF_INET, AF_INET6, AF_LOCAL, AF_ROUTE, AF_KEY)

    type:套接字类型 (SOCK_STREAM, SOCK_DGRAM, SOCK_SEQPACKET, SOCK_RAW)

    protocol:协议,可以为0(选择所给定family和type组合的系统默认值) (IPPROTO_TCP, IPPROTO_UDP, IPPROTO_SCTP)

    AF_XXX和PF_XXX分别代表地址族和协议族,然而现在其实是相等的。(address family && protocol family)

    2、connect函数:

    #include <sys/socket.h>
    int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);    // 成功返回0,出错返回-1
    

    sockfd:套接字描述符,socket函数返回的

    servaddr:要连接的服务器套接字地址结构,通常需要强制转换(sockaddr是通用套接字地址结构)

    addrlen:servaddr的结构大小,sizeof(servaddr)

    客户端调用函数connect之前不必一定调用bind函数,如果调用的话,内核会确定源IP地址并选择一个临时端口作为源端口。

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

      1) TCP客户没有收到SYN分节的响应,返回ETIMEDOUT错误。(会多次发送SYN,都无响应的话会返回这个错误)

      2) 相应是RST(复位),表明该服务器主机在指定的端口上没有进程在等待与之连接(可能进程没有在运行),客户一接收到RST就马上返回ECONNREFUSED错误。

        RST产生的条件:端口上没有正在监听的服务器;TCP想取消一个已有连接;TCP接收到一个根本不存在的连接上的分节。

      3) SYN在中间的某个路由器上引发一个“destination unreachable”(目的地不可达)ICMP错误,则认为是一种软错误(soft error)。客户主机内核保存该消息,并按第一种情况中的时间间隔继续发送SYN。如果在规定的时间内仍然未收到响应,则把保存的消息(ICMP错误)作为EHOSTUNREACH或ENETUNREACH错误返回给进程。(导致的原因:按照本地系统的转发表,根本没有到达远程系统的路径;connect调用根本不等待就返回。)

    以下是我做的测试:(ip:192.168.1.100)

    第一个例子显示No route to host,表示是一个因特网不可到达的IP地址

    第二个例子显示Connection timed out(等待很久才返回这个结果),表示可以连接到路由,但是不存在这样的一个IP地址

    第三个例子正常显示,然后关闭服务器程序

    最后一个例子显示Connection refused,是因为主机存在,但是端口已经没有被占用,服务器会立刻响应一个RST分节

    connect函数导致当前套接字从CLOSED状态转移到SYN_SENT状态,若成功则再转移到ESTABLISHED状态。

    若connect失败则该套接字不再可用,必须关闭,故当循环调用函数connect,每当失败,都必须close当前的套接字描述符并重新调用socket。

    3、bind函数:

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

    第二个参数是指向特定于协议的地址结构指针,这个结构体可以指定IP和端口,也可以不指定。(IP必须属于其所在主机的网络接口之一)

    IP地址置为通配地址,或端口置为0,都是由内核自动选择IP和临时端口。

    如果是内核选择的临时端口,由于第二个参数是const的,所以要想返回端口值,必须调用getsockname来返回协议地址。

    在为多个组织提供Web服务器的主机上,需要捆绑非通配IP地址。

    调用bind函数常见错误是EADDRINUSE(地址已使用)

    4、listen函数:

    #include <sys/socket.h>
    int listen(int sockfd, int backlog);    // 成功返回0,出错返回-1
    

    TCP服务器调用,要做的两件事情:

      1) socket函数创建一个套接字时,默认是一个主动套接字,listen函数把一个未调用connect的未连接的套接字转换成一个被动套接字,指示内核应接收指向该套接字的连接请求。(主动/客户 -> 被动/服务器)

      2) backlog参数指定套接字排队的最大连接个数

    listen函数导致当前套接字从CLOSED状态转移到LISTEN状态。

    本函数在socket和bind之后,在accept之前。

    内核为任何一个给定的监听套接字维护两个队列:

      1) 未完成连接队列:处于SYN_RCVD状态的客户套接字

      2) 已完成连接队列:处于ESTABLISHED状态的客户套接字

    这两个队列之和不超过backlog(有些不是backlog这个值,而是对应backlog的一个值),服务器每次accept是从已完成队列队头取得一个返回,而完成三次握手的套接字转移到已完成队列,如果该队列为空,进程将被投入睡眠,直到TCP在该队列中放入一项才唤醒它。

    每个系统的backlog参数与已排队连接的实际数对应关系都不相同,但是不要把它置为0,如果不想让任何客户连接上监听套接字,则关掉它。

    为了动态改变backlog参数,一种方法是通过命令行选项或环境变量覆写默认值。

    void
    Listen(int fd, int backlog)
    {
    	char	*ptr;
    
    		/*4can 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,即不会马上报错,而等待重发),期望不久就能在这些队列中找到可用空间。目的是为了让客户区分“该端口没有服务器在监听”和“该端口有服务器在监听,但是队列满”

    5、accept函数:

    #include <sys/socket.h>
    int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);    // 成功返回非负描述符,出错返回-1
    

    第一个参数表示原来(socket创建)的监听套接字描述符

    第二个参数返回已连接的对端进程(客户)的协议地址

    第三个参数是值-结果参数,调用前置为cliaddr所指的套接字地址结构的长度,返回的是内核存放该套接字地址结构的确切字节数

    返回值是已连接套接字描述符,注意区分已连接套接字和监听套接字:监听套接字在服务器的生命期内一直存在,内核为每个由服务器进程接受的客户连接创建一个已连接套接字,当服务器完成对某个给定客户的服务时,相应的已连接套接字就被关闭。

    第2、3个参数,如果对返回客户协议地址没有兴趣,可以将后两个参数置为空指针。

    #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
    ",
    			   Inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
    			   ntohs(cliaddr.sin_port));
    
            ticks = time(NULL);
            snprintf(buff, sizeof(buff), "%.24s
    ", ctime(&ticks));
            Write(connfd, buff, strlen(buff));
    
    		Close(connfd);
    	}
    }
    

    这个程序是每次有连接的时候,在服务端输出来自哪个IP和端口的连接。

    可以知道,没有调用bind的客户端程序会绑定一个临时端口用于连接。

    ----- 并发服务器 -----

    6、fork函数:

    #include <unistd.h>
    pid_t fork(void);    // 返回两次,子进程中返回0,父进程中为子进程id,出错返回-1
    

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

    调用这个函数,会派生一个新进程,于是在父进程中返回了子进程的id,在子进程中返回0。(成功的情况下)

    所以,返回值可以判断目前是在子进程还是父进程中。

    父进程在调用fork之前打开的所有描述符在fork返回之后由子进程分享,于是通常情况下,父进程调用accept之后调用fork,子进程接着读写这个已连接套接字,父进程则关闭这个已连接套接字,达到并发。

    fork的典型用法:

      1) 一个进程创建一个自身的副本,这样每个副本都可以在另一个副本执行其他任务的同时处理各自的某个操作,这就是网络服务器的典型用法。

      2) 一个进程想要执行另一个程序,通常是父进程创建一个副本(子进程),子进程调用exec把自身替换成新的程序,如shell之类的程序的典型用法。

    7、exec函数:

    #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
    

    这几组函数区别在于:

      1) 待执行的程序文件是由文件名(filename)还是由路径名(pathname)指定。

      2) 新程序的参数是一一列出还是由一个指针数组来引用

      3) 把调用进程的环境传递给新程序还是给新程序指定新的环境

    这些函数在失败的时候才返回-1给调用者,否则不返回,控制将被传递给新程序的起始点。

    execve是内核中的系统调用,其他5个都是调用execve的库函数。

    上面行的3个函数把新程序的每个参数字符串指定成exec的一个独立参数,并以一个空指针结束可变数量的参数。下面行的3个函数则有一个作为exec参数的argv数组,其中含有指向新程序各个参数字符串的所有指针(argv数组必须含有一个用于指定其末尾的空指针)。

    左列2个函数,指定了一个filename参数,exec将使用当前的PATH环境变量把该文件名参数转换为一个路径名,但filename参数中如果含有斜杠(/),就不再使用环境变量 (我的理解这个是相对路径(?)),而右两列4个函数指定一个全限定的pathname参数(所以这个是绝对路径(?) )。

    左两列4个函数不显式指定一个环境指针,它们使用外部变量environ的当前值来构造一个传递给新程序的环境列表。右列2个函数显式指定,envp指针数组必须以一个空指针结束。

    进程在调用exec之前打开的描述符通常跨exec继续保持打开,但是这个默认行为可以使用fcntl设置FD_CLOEXEC描述符标志禁止掉。(后面才有讲的)

    ----- 分割线 -----

    并发服务器:

    Unix中编写并发服务器程序最简单的办法就是fork一个子进程来服务每个客户。

    // 并发服务器轮廓
    pid_t pid; int listenfd,connfd; listenfd = socket( ... ); bind(listen, ... ); listen(listenfd,LISTENQ); for(;;) { connfd = accept(listenfd, ... ); if((pid = fork()) == 0) {
       // 子进程 关闭 监听套接字,并 处理 事务 close(listenfd); doit(connfd); close(connfd); exit(0); }
       // 父进程 关闭 已连接套接字,继续accept close(connfd); }

    在父进程close(connfd)的时候,子进程可能仍然在doit(connfd),此时TCP套接字不会发送FIN并终止与客户连接,原因是:

    每个文件或套接字都有一个引用计数(ls -al时候回显的连接数),它是当前打开着的引用该文件或套接字的描述符的个数。

    所以,父进程关闭connfd时,只是把相应的引用计数值从2减为1,该套接字真正的清理和资源释放要等到其引用计数值到达0时才发生。(子进程也关闭connfd的时候)

    8、close函数:

    #include <unistd.h>
    int close(int sockfd);    // 成功返回0,出错返回-1
    

    close函数也用来关闭套接字并终止TCP连接,close一个TCP套接字的默认行为是把该套接字标记成已关闭,然后立即返回到调用进程。该套接字描述符不能再由调用进程使用,不能再作为read或write的第一个参数。调用之后,TCP将尝试发送已排队等待发送到对端的任何数据,发送完毕后发生的是正常的TCP终止序列。后面章节的SO_LINGER选项可以改变默认行为(可以确信对端进程已收到所有未处理数据)

    引用计数大于0的时候不会引发TCP的四次挥手,但是如果确实想让TCP发送一个FIN,可以使用shutdown函数代替close。

    很重要的一点:如果父进程对每个由accept返回的已连接套接字都不调用close,那么父进程最终将耗尽可用描述符,并且没有一个客户连接会被终止(子进程退出之后,引用计数减为1,因为父进程永不关闭已连接套接字,所以不会发送FIN)

    9、getsockname和getpeername函数:

    #include <sys/socket.h>
    int getsockname(int sockfd, const struct sockaddr *localaddr, socklen_t addrlen);
    int getpeername(int sockfd, const struct sockaddr *peeraddr, socklen_t addrlen);
    // 成功返回0,出错返回-1
    

    注意:调用这两个函数返回的是IP地址和端口的组合,并不是域名。

    这两个函数的用处:

      1) 没有bind的TCP客户上,connect成功返回后,getsockname用于返回由内核赋予该连接的本地IP地址和本地端口号

      2) 在以端口号0调用bind后,getsockname用于返回由内核赋予的本地端口号

      3) getsockname可用于获取某个套接字的地址族,如下面的代码所示

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

      4) 在通配IP地址调用bind的TCP服务器,一旦建立连接(accept成功返回),getsockname就可以用于返回由内核赋予该连接的本地IP地址。(sockfd必须赋已连接套接字描述符)

      5) 当一个服务器是由调用过accept的某个进程通过调用exec执行程序时,它能够获取客户身份的唯一途径便是调用getpeername。(无论是父进程还是子进程,都可以使用accept返回的地址结构,但是调用exec之后,子进程的内存映像被替换成新的文件,此时,只有套接字描述符仍然开放 -> (可以使用,但是需要当做参数或者别的方法传递过去),所以只有getpeername可以使用)

        exec新程序如何获取已连接套接字描述符:1) 调用exec的进程可以把这个描述符号格式化成一个字符串,再把它作为一个命令行参数传递给新程序。 2) 约定在调用exec之前,总是把某个特定描述符置为所接受的已连接套接字描述符(?) -> inetd采用的方法

    总结:

    客户端:socket -> connect -> close

    服务器:socket -> bind -> listen -> accept -> close

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

  • 相关阅读:
    java的异常
    Quartz使用总结
    MYSQL性能优化的最佳20+条经验
    索引原理和慢查询优化
    MySQL索引背后的数据结构及算法原理
    常见电商项目的数据库表设计(MySQL版)
    常见试题和算法
    mysql性能调优与架构设计笔记
    MYSQL数据库设计规范与原则
    复合索引的优点和注意事项
  • 原文地址:https://www.cnblogs.com/tyrus/p/unp_ch4.html
Copyright © 2020-2023  润新知