《Unix网络编程》卷1 中级

本文是《Unix网络编程卷1》的中级篇,深入讲解TCP套接字编程,涵盖socket、connect、bind、listen、accept等基本操作,以及并发服务器、I/O复用(select和poll)、套接字选项、UDP编程等内容。通过示例程序展示了TCP客户端和服务器的工作流程,分析了连接过程中可能遇到的问题,如僵尸进程、中断处理和服务器崩溃等情况,并探讨了如何通过I/O复用机制提高并发处理能力。
摘要由CSDN通过智能技术生成

基本TCP套接字编程

Ref: 《UNIX网络编程卷1》–笔记

socket

  • 函数:int socket(int framily, int type, int protocal);
    • framily参数表明协议族(协议域),
    • type参数表示套接字类型
    • protocal表示协议类型(或则设置为0)
  • 并不是所有的framilytype的组合都是有效的
  • AF_前缀表示地址族,PF_前缀表示协议族
  • socket函数的返回值为一个非负整数(套接字描述符, sockfd),套接字描述符知识制定了协议族和套接字类型,并没有指定本地协议或则远程协议
    在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

connect

  • 函数:int connect(int sockfd, const struct sockaddr* servaddr, socklen_t addrlen);
    • sockfd:套接字描述符
    • 第二三个参数表示一个套接字地址结构(内部有服务器IP+Port)
    • 出错的情况:
      • TCP客户没有收到SYN分节的响应,如往本地子网上一个不存在的IP发送SYN
      • 硬错误:收到RST
      • 产生RST的三个条件:
        • 目的地为某端口的SYN到达,然而端口上没有正在监听的服务器;
        • TCP想取消一个已有连接;
        • TCP接收到一个根本不存在的连接上的分节.
      • 软错误: 发送SYN分节引发路由器“destination unreachable”ICMP错误。
    #include <sys/socket.h>
    int connect(int sockfd, const sockaddr * servaddr, socklen_t addrlen);
    //成功返回0,出错为-1
    

bind

  • 常见错误“address already in use”
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
//成功返回0,出错返回-1

listen

  • 监听套接字维护两个队列:
    • 未完成连接队列(SYN_RCVD)和已完成连接队列(ESTABLISHED)。
    • backlog要求这两个队列之和不超过它。
#include <sys/socket.h>
int listen(int sockfd, int backlog);
成功返回0,出错返回-1

accept

  • accept拥有两个值-结果参数,cliaddr和addrlen可以返回peer端信息,如果不关心,可以置NULL。
    #include <sys/socket.h>
    int accept(int sockfd, struct sockaddr * cliaddr, socklen_t *addrlen);
    //成功返回非负描述符号,出错返回-1
    

close()

  • int close(sockfd);:可以用来关闭套接字,并终止TCP连接
  • 确实想终止连接可以用 shutdown()函数

服务器: 显示客户端IP和端口号

/* 服务器端显示客户端的ip地址和端口号 */
#include <time.h>
#include "unp.h"
 
#define MAXLINE 4096
#define LISTENQ 1024
//#define SA struct sockaddr
typedef struct sockaddr SA;
typedef int socket_t; // 2017.08.06
 
int main(int argc, char **argv)
{
   
	int					listenfd, connfd;
	//struct sockaddr_in	servaddr;
	struct sockaddr_in	servaddr, cliaddr; // 2017.08.06
	socket_t			len; // 2017.08.06
	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(1300);	/* daytime server */
 
	bind(listenfd, (SA *) &servaddr, sizeof(servaddr)); // 强转为通用套接字地址结构
 
	listen(listenfd, LISTENQ); // 转化为监听套接字
 
	for ( ; ; ) {
   
		len = sizeof (cliaddr); // 2017.08.06
		connfd = accept(listenfd, (SA *)&cliaddr, &len); // 2017.08.06
		printf("connection from %s, port %d\n",
				inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
				ntohs(cliaddr.sin_port)); // 2017.08.06
 
        ticks = time(NULL);
        snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));
        write(connfd, buff, strlen(buff));
 
		close(connfd);
	}
}

并发服务器

/* 伪代码 */
pid_t pid;
int   listenfd, connfd;
listenfd = socket (...);
bind (listenfd, ...);
listen (listenfd, LISTENQ);
for (; ; ) {
   
    connfd = accept (listenfd, ...);
    if ((pid = fork()) == 0) {
   
        close (listenfd); /* child closes listening socket */
        /* do something */
        close (connfd);   /* done with this client */
        exit (0);
    }
    close (connfd);       /* parent closes connected socket */
}

本地和外地协议地址函数

#include <sys/socket.h>
int getsockname (int sockfd, struct sockaddr *localaddr, socklen_t *addrlen);
int getpeername (int sockfd, struct sockaddr *peeraddr, socklen_t *addrlen);
  • 在一个没有调用bind的TCP客户端上,connect成功返回后,getsockname用于返回由内核赋予该连接的本地IP地址和本地端口号;
  • 在以端口号0调用bind后,getsockname用于返回由内核赋予的本地端口号;
  • getsockname可用于获取某个套接字的地址族。
  • 当一个服务器是由调用过accept的某个进程通过调用exec执行程序时,它能够获取客户身份的唯一途径便是调用getpeername
/* 代码演示:获取套接字的地址族 */
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);
}
  • 大多数TCP服务器是并发的,大多数UDP服务器是迭代的。

TCP客户端和服务器程序示例

本章开始编写一个完整的TCP客户/服务器程序实例。
(1) 客户冲标准输入读入一行文本,并写给服务器
(2)服务器从网络输入读入这行文本,并回射给客户
(3)客户从网络读入这行回射文本,并显示在标准输出上。
完整的TCP客户/服务器程序实例

Client

#include "unp.h"
int main(int argc, char **argv)
{
   
	int sockfd;
	struct sockaddr_inservaddr;
	
	if (argc != 2)
	err_quit("usage: tcpcli <IPaddress>");
	
	sockfd = Socket(AF_INET, SOCK_STREAM, 0);
	
	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_port = htons(SERV_PORT);
	Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
	
	Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));
	
	str_cli(stdin, sockfd);/* do it all */
	exit(0);
}
void str_cli(FILE *fp, int sockfd)
{
   
	char sendline[MAXLINE], recvline[MAXLINE];
	while (Fgets(sendline, MAXLINE, fp) != NULL) {
   
		Writen(sockfd, sendline, strlen(sendline) );
		if (Readline(sockfd, recvline, MAXLINE) == 0)
		err_quit("str_cli: server terminated prematurely");
		Fputs(recvline, stdout);
	}
}

Server

#include "unp.h"
int main(int argc, char **argv) {
   
	int listenfd, connfd;
	pid_t childpid;
	socklen_t clilen;
	struct sockaddr_incliaddr, servaddr;
	
	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(SERV_PORT);
	
	Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
	Listen(listenfd, LISTENQ);
	
	for ( ; ; ) {
   
		clilen = sizeof(cliaddr);
		connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);
		if ( (childpid = Fork()) == 0) {
   /* child process */
			Close(listenfd);/* close listening socket */
			str_echo(connfd);/* process the request */
			exit(0);
		}
		Close(connfd);/* parent closes connected socket */
	}
}
void str_echo(int sockfd) {
   
	ssize_t n;
	char buf[MAXLINE];
	
	again:
	while ( (n = read(sockfd, buf, MAXLINE)) > 0)
	Writen(sockfd, buf, n);
	
	if (n < 0 && errno == EINTR)
	goto again;
	else if (n < 0)
	err_sys("str_echo: read error");
}

工作流程

  • 服务端先在后台运行。

    • 连接阶段:
      • socket 创建套接字,
      • 调用bind 设置服务的端口号为9877, 任意一个网卡的IP,
      • 调用listen,将套接字改为被动连接套接字,
      • 维护队列,这一步完成后就可以接收客户的connect了,
      • 调用accept,初次调用时并没有已连接的套接字,进入睡眠。
    • 工作阶段:
    • 创建子进程:
      • accept放在一个无限循环中,
      • accept返回成功,就fork一个子进程,
      • 在子进程中处理已建立连接的任务,父进程就继续等待下一个连接。
    • 子进程工作:
      • 在子进程中需要关闭socket创建的描述符,父进程中关闭connect返回的描述符,
        • 因为fork创建进程时这两个描述符都会复制到子进程中,如果不关闭,在子进程退出时由于父进程还打开了connect描述符,将不会发送FIN字节,而且每一个连接都会消耗一个描述符资源永远不会释放。
    • str_echo中,服务器从套接字中读取内容,若没有内容就阻塞,然后直接写回套接字。
  • 客户端

    • 链接阶段:
      • 创建套接字,
      • 设置服务器IP和端口好,
      • 调用connect发起连接,
        • 调用connect后会发送SYN字节,
        • 在收到服务端的ACK后,
        • connect就返回,进入established状态。
    • 工作阶段
      • 从标准输入中读取一行文本
      • 将它写到套接字中
      • 从套接字中读一行文本
      • 写到标准输出

客户和服务器正常启动

  • 客户端正常是阻塞在fgets,等待用户输入。
  • 在用户输入EOF后,fgets返回NULLstr_cli退出
  • 客户端程序调用exit结束程序,
    • exit首先会先关闭打开的套接字描述符,(客户单套接字close)
    • 引发FIN发送到套接字中,进入FIN_WAIT_1状态,(客户端发送FIN)
    • 收到服务器的ACK后进入FIN_WAIT_2状态,(服务器发送回复:ACK)
    • 再收到FIN后发送ACK然后进入TIME_WAIT状态(服务器发送:FIN, 客户端回复:ACK)
    • 等待2MSL
  • 客户端程序运行时查看套接字状态:
$ netstat -a |grep 9877
tcp        0      0 *:9877                  *:*                     LISTEN     
tcp        0      0 localhost:36368         localhost:9877          ESTABLISHED
tcp        0      0 localhost:9877          localhost:36368         ESTABLISHED
  • 客户端程序终止运行后查看套接字状态:
$ netstat -a |grep 9877
tcp        0      0 *:9877                  *:*                     LISTEN     
tcp        0      0 localhost:36368         localhost:9877          TIME_WAIT

问题分析:

僵尸进程

  • 用 ps 查看程序状态发现存在僵尸进程
$ ps -o pid,ppid,stat,args
  PID  PPID STAT COMMAND
30143 30142 Ss   -bash
34810 30143 S    ./tcpserv01
34812 34810 Z    [tcpserv01] <defunct>
34813 30143 R+   ps -o pid,ppid,stat,args
  • 避免产生僵尸进程
  1. 让父进程调用waitwaitpid.
    • 子进程在结束后内核会向父进程发送一个SIGCHLD信号,通知父进程子进程已经结束。
    • 这时父进程如果设置了信号处理函数那么就可以在信号处理函数中调用waitwaitpid.
    • 如果创建的子进程不止一个,
      • 那么需要在一个循环中调用waitpid来处理,并且设置WNOHANG参数,
      • 因为一个wait/waitpid只处理一个僵尸进程,而且调用wait时会挂起,这在信号处理函数中是不妥的。
      • 如果父进程不设置信号处理函数,那么就可以再父进程退出时调用wait,或waitpid,通常这种情况下父进程都是很快就退出,不然还是会产生僵尸进程。
  2. 让init进程处理僵尸进程。
    • 这种情况下存在于:
      • 父进程没有处理SIGCHLD信号,信号处理函数中没有waitpid
      • 父进程已经结束后才存在的情况。
    • 这时init就会成为僵尸进程的父进程,我们就不用管了。
      • 其实这中情况多半是由于父进程忘记处理了。
    • 这里我们可以不处理SIGCHLD信号,因为这个信号并不会导致程序结束,只要在父进程中close后面调用wait / waitpid,就可以了。

考虑慢系统调用被中断的情况

  • 为了说明这个问题,我们引入信号处理函数,其实信号处理就相当于一个软件中断,中断随时都可能发生,因此我们编写代码过程中需要考虑中断的情况。
int main(int argc, char **argv){
   
	int listenfd, connfd;
	pid_t childpid;
	socklen_t clilen;
	struct sockaddr_incliaddr, servaddr;
	void sig_chld(int signo);
	Sigfunc * Signal(int signo, Sigfunc *func);
	
	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(SERV_PORT);

	Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
	Listen(listenfd, LISTENQ);
	Signal(SIGCHLD, sig_chld);

	for ( ; ; ) {
   
		clilen = sizeof(cliaddr);
		connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);
		
		if ( (childpid = Fork()) == 0) {
   /* child process */
			Close(listenfd);/* close listening socket */
			str_echo(connfd);/* process the request */
			exit(0);
		}
		Close(connfd);/* parent closes connected socket */
	}
}

void sig_chld(int signo){
   
	pid_t pid;
	int stat;
	printf("enter sig_chld\n");
	while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0)
	//while( (pid = wait(NULL)) > 0)
	printf("child %d terminated\n", pid);
	printf("quit sig_chld\n");
	return;
}
  • 上面例子中中断处理函数中调用printf是不太合适的,因为printf是不可重入函数,在程序规模比较大,进程多时可能出现奇怪错误,这里只为了查看程序状态。

  • Signal是一个书中作者写的一个包裹函数,采用signation函数实现,实现代码中可以设置是否设置SA_RESTART, 这个配置就表示当系统调用被中断以后是否自动重新启动

  • 因为不同的UNIX系统实现可能不一样,有些系统默认重启有些则默认不重启,因此我们自己配置就可以更好控制,当然也为了不用直接配置signation,才将其包装起来。

  • 对于acceptreadwriteselect等慢系统调用通常我们都希望他们被中断之后能继续返回中断前的状态继续执行因为并不会产生错误,而对于connect在中断之后我们则不能重启因为在中断之后其连接肯定会失败

  • 当服务器在accept阻塞时,假如进程突然崩溃,

    • 此时子进程退出时:
      • FIN字节发送到套接字,客户端收到后回应以一个ACK
      • 同时内核向父进程发送一个SIGCHLD信号,父进程调用sig_chil处理,处理完成后返回accept调用
    • 那么这时问题就来了,如果没有配置自动重启标识,
      • accept调用将出错,并将errno 设为EINTR
      • 正确的处理应该是退出程序,但是显然我们不希望这个结果。
  • 解决方案:

    • 在配置信号处理函数时,设置act.sa_flags |= SA_RESTART;这样当accept被中断返回后,能继续 阻塞。
    • 修改accept的判断条件: 当accept返回错误时,我们可以判断一下是否errnoEINTR,如果是我们就手动重启accept
      • Code: 注意这里我们调用的时accept 而不是 包裹函数Accept.
      connfd = accept(listenfd, (SA *) &cliaddr, &clilen);
      if(connfd < 0){
             
      	if (errno == EINTR){
             
      		continue;
      	} else {
             
      		err_sys("accept error");
      	}
      }
      

服务器进程意外终止

  • 这个问题也可以用上面的情形测试,我们通过kill掉服务器子进程来模拟。
  • 对于客户端
    • 当服务器终止后会发送的FIN字节(表明服务端不在发送内容),客户端自动以ACK回应
    • 然后服务器被强行毙掉
    • 但客户端并不知道服务器进程已经被毙掉了(它只收到了FIN,并不能说明它被毙掉了),因为客户端此时是阻塞于fgets的,并不会发送FIN字节给服务器,此时客户端认为链接并没有关闭,因此一直等待用户从标准输入输入字符
    • 如果用户一直不输入那么程序永远不知道服务器已经挂了。
    • 当用户输入一些字符的时候,服务器就会回应一个RST,客户才知道服务器已经挂了,
    • 如果客户继续发送内容将引发SIGPIPE信号(这种情况很可能发生,因为客户发给服务端的内容可能是分几次发送的,第一次发的时候就回收到RST,在收到RST期间还可能发送很多内容)。
  • 如何解决:
    • 这个问题的根本原因在于客户端,它不能仅仅阻塞于fgets,它应该同时关注stdinsocket ,任意一个退出都应该及时知道。因此可以使用select来管理这2个描述符。

发送数据格式有限制

当发送字符串时一般没什么问题,只要不同主机都支持同一中字符编码,但是如果发送的是二进制就有很多问题,比如不同主机字节序可能不同、CPU位数不同,各种数据类型占用空间以及对齐格式可能不同,这其实也是二进制文件的兼容性问题,因此兼容难度非常大。

服务器崩溃 或者网络中断

TCP有重传机制,当网络不通时,客户端将不停地重传未收到确认的分组,直到放弃。。。这里可能需要很久的时间,我们当然希望能尽快知道服务器崩溃的消息了,利用SO_KEEPALIVE套接字选项就可以解决这个问题。

I/O复用:select和poll

  • UNIX下可用的5种I/O模型:
    • 阻塞式I/O;
    • 非阻塞式I/O;
    • I/O复用;
    • 信号驱动式I/O;
    • 异步I/O。
      UNIX下可用的5种I/O模型

I/O复用采用轮询的方式处理多个描述符,当有文件准备好时,就通知进程。

  • 关注点
    • I/O复用的应用场合
    • 采用I/O复用的客户端和服务器程序
  • I/O复用的应用场合
    • 当客户处理多个描述符时(通常是交互式输入和网络套接字),必须使用I/O复用,才能即使告知用户程序套接字的情况
    • 如果一个TCP服务器既要处理监听又要处理连接套接字,一般要用I/O复用
    • 如果既要处理TCP,又要处理UDP,一般要用I/O复用
    • 如果一个服务器要处理多个服务或多个协议如inet守护进程,一般要用I/O复用

select

  • int select(int maxfdp1,fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
    • timeout:告知内核等待指定描述符中的任何一个就绪需要花多少时间
      struct timeval {
             
      	long tv_sec; /* seconds */
      	long tv_usec; /* microseconds ,许多UNIX向上舍10ms整数倍,再加调度延迟,时间更不准确*/
      }
      
      • 表示永远等待下去:置空指针,仅在有描述符准备好I/O时才返回
      • 等待一段固定的时间:由timeout指定
      • 根本不等待:定时器值置为0,这称为轮询(poll)
    • fd_set变量使用例子(maxfdp1设置为6):注意时值-结果参数(返回以后需要重新对感兴趣的位置1)
      fd_set rset;
      FD_ZERO(&rset);
      FD_SET(1, &rset);
      FD_SET(4, &rset);
      FD_SET(5, &rset);
      
    • 计时器到时返回0,-1表示出错
  • 描述符就绪的条件
    • 一个套接字准备好读的情况:
      • 接收缓冲区中字节数 >= 接收缓冲区低水位标记的当前大小(默认1,由SO_RCVLOWAT设置)
      • 读半部关闭(接收了FIN)将不阻塞并返回0
      • 监听套接字的已连接数不为0,这时accept通常不阻塞
      • 其上有一个套接字错误待处理,返回-1error设置成具体的错误条件,可通过SO_ERROR套接字选项调用getsockopt获取并清除
    • 一个套接字准备好写
      • 以连接套接字或UDP套接字发送缓冲区中的可用字节数 >= 发送缓冲区低水位标记的当前大小(默认2048,可用SO_SNDLOWAT)
      • 写半部关闭的套接字,写操作将产生一个SIGPIPE信号
      • 非阻塞式connect的套接字已建立连接,或者connect以失败告终
      • 其上有一个套接字错误待处理,返回-1error设置成具体的错误条件,可通过SO_ERROR套接字选项调用getsockopt获取并清除
        在这里插入图片描述
  • 混合使用stdio和select被认为是非常容易犯错误的
    • readline缓冲区中可能有不完整的输入行
    • 也可能有一个或多个完整的输入行

shutdown

  • int shutdown(int sockfd, int howto)
    • close()把描述符的引用计数减1shutdown直接激发TCP的正常连接序列的终止
    • shutdown告诉对方我已近完成了数据的发送(对方仍然可以发给我)
      • SHUT_RD:关闭连接的读这一半
        • 可以把第二个参数置为SHUT_RD防止回环复制
        • 关闭SO_USELOOPBACK套接字选项也能防止回环
      • SHUT_WR:关闭连接的写这一半,也叫半关闭
      • SHUT_RDWR:连接的读半部和写半部都关闭

TCP回射服务器程序

  • 使用selecet的客户端程序
    • 版本一:中调用了FetsFputsReadline等有自己缓冲区的函数,select看不到,这将导致缓冲区中的数据来不及消费。
      void str_cli(FILE * fp, int sockfd){
             
        char sendline[MAXLINE], recvline[MAXLINE];
        int maxfdp1;
        fd_set rset;
        FD_ZERO(
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值