《Unix网络编程》卷1 中级
基本TCP套接字编程
Ref: 《UNIX网络编程卷1》–笔记
socket
- 函数:
int socket(int framily, int type, int protocal);
framily
参数表明协议族(协议域),type
参数表示套接字类型protocal
表示协议类型(或则设置为0)
- 并不是所有的
framily
和type
的组合都是有效的 - 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)客户从网络读入这行回射文本,并显示在标准输出上。
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字节
,而且每一个连接都会消耗一个描述符资源永远不会释放。
- 因为fork创建进程时这两个描述符都会复制到子进程中,如果不关闭,在子进程退出时由于父进程还打开了
- 在子进程中需要关闭
- 在
str_echo
中,服务器从套接字中读取内容,若没有内容就阻塞,然后直接写回套接字。
- 连接阶段:
-
客户端
- 链接阶段:
- 创建套接字,
- 设置服务器IP和端口好,
- 调用connect发起连接,
- 调用connect后会发送SYN字节,
- 在收到服务端的ACK后,
- connect就返回,进入established状态。
- 工作阶段
- 从标准输入中读取一行文本
- 将它写到套接字中
- 从套接字中读一行文本
- 写到标准输出
- 链接阶段:
客户和服务器正常启动
- 客户端正常是阻塞在
fgets
,等待用户输入。 - 在用户输入
EOF
后,fgets
返回NULL
,str_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
- 避免产生僵尸进程
- 让父进程调用
wait
或waitpid
.- 子进程在结束后内核会向父进程发送一个
SIGCHLD
信号,通知父进程子进程已经结束。 - 这时父进程如果设置了
信号处理函数
那么就可以在信号处理函数中调用wait
或waitpid
. - 如果创建的子进程不止一个,
- 那么需要在一个循环中调用
waitpid
来处理,并且设置WNOHANG
参数, - 因为一个
wait/waitpid
只处理一个僵尸进程,而且调用wait
时会挂起,这在信号处理函数中是不妥的。 - 如果父进程不设置信号处理函数,那么就可以再父进程退出时调用
wait,或waitpid
,通常这种情况下父进程都是很快就退出,不然还是会产生僵尸进程。
- 那么需要在一个循环中调用
- 子进程在结束后内核会向父进程发送一个
- 让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
,才将其包装起来。 -
对于
accept
、read
、write
、select
等慢系统调用通常我们都希望他们被中断之后能继续返回中断前的状态继续执行,因为并不会产生错误,而对于connect
在中断之后我们则不能重启,因为在中断之后其连接肯定会失败
。 -
当服务器在
accept
阻塞时,假如进程突然崩溃,- 此时
子进程
退出时:- 发
FIN
字节发送到套接字,客户端收到后回应以一个ACK
- 同时内核向父进程发送一个
SIGCHLD
信号,父进程调用sig_chil
处理,处理完成后返回accept
调用
- 发
- 那么这时问题就来了,如果没有配置自动重启标识,
accept
调用将出错,并将errno
设为EINTR
,- 正确的处理应该是退出程序,但是显然我们不希望这个结果。
- 此时
-
解决方案:
- 在配置信号处理函数时,设置
act.sa_flags |= SA_RESTART
;这样当accept
被中断返回后,能继续 阻塞。 - 修改
accept
的判断条件: 当accept
返回错误时,我们可以判断一下是否errno
为EINTR
,如果是我们就手动重启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
,它应该同时关注stdin
和socket
,任意一个退出都应该及时知道。因此可以使用select
来管理这2个描述符。
- 这个问题的根本原因在于客户端,它不能仅仅阻塞于
发送数据格式有限制
当发送字符串时一般没什么问题,只要不同主机都支持同一中字符编码,但是如果发送的是二进制就有很多问题,比如不同主机字节序可能不同、CPU位数不同,各种数据类型占用空间以及对齐格式可能不同,这其实也是二进制文件的兼容性问题,因此兼容难度非常大。
服务器崩溃 或者网络中断
TCP有重传机制,当网络不通时,客户端将不停地重传未收到确认的分组,直到放弃。。。这里可能需要很久的时间,我们当然希望能尽快知道服务器崩溃的消息了,利用SO_KEEPALIVE套接字选项就可以解决这个问题。
I/O复用:select和poll
- UNIX下可用的5种I/O模型:
- 阻塞式I/O;
- 非阻塞式I/O;
- I/O复用;
- 信号驱动式I/O;
- 异步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表示出错
- timeout:告知内核等待指定描述符中的任何一个就绪需要花多少时间
- 描述符就绪的条件
- 一个套接字准备好读的情况:
- 接收缓冲区中字节数
>=
接收缓冲区低水位标记的当前大小(默认1
,由SO_RCVLOWAT
设置) - 读半部关闭(接收了
FIN
)将不阻塞并返回0
- 监听套接字的已连接数不为
0
,这时accept
通常不阻塞 - 其上有一个套接字错误待处理,返回
-1
,error
设置成具体的错误条件,可通过SO_ERROR
套接字选项调用getsockopt
获取并清除
- 接收缓冲区中字节数
- 一个套接字准备好写
- 以连接套接字或
UDP
套接字发送缓冲区中的可用字节数>=
发送缓冲区低水位标记的当前大小(默认2048
,可用SO_SNDLOWAT
) - 写半部关闭的套接字,写操作将产生一个
SIGPIPE
信号 - 非阻塞式
connect
的套接字已建立连接,或者connect
以失败告终 - 其上有一个套接字错误待处理,返回
-1
,error
设置成具体的错误条件,可通过SO_ERROR
套接字选项调用getsockopt
获取并清除
- 以连接套接字或
- 一个套接字准备好读的情况:
- 混合使用stdio和select被认为是非常容易犯错误的
- readline缓冲区中可能有不完整的输入行
- 也可能有一个或多个完整的输入行
shutdown
int shutdown(int sockfd, int howto)
close()
把描述符的引用计数减1
,shutdown
直接激发TCP
的正常连接序列的终止shutdown
告诉对方我已近完成了数据的发送(对方仍然可以发给我)SHUT_RD
:关闭连接的读这一半- 可以把第二个参数置为
SHUT_RD
防止回环复制 - 关闭
SO_USELOOPBACK
套接字选项也能防止回环
- 可以把第二个参数置为
SHUT_WR
:关闭连接的写这一半,也叫半关闭SHUT_RDWR
:连接的读半部和写半部都关闭
TCP回射服务器程序
- 使用
selecet
的客户端程序- 版本一:中调用了
Fets
,Fputs
,Readline
等有自己缓冲区的函数,select
看不到,这将导致缓冲区中的数据来不及消费。void str_cli(FILE * fp, int sockfd){ char sendline[MAXLINE], recvline[MAXLINE]; int maxfdp1; fd_set rset; FD_ZERO(
- 版本一:中调用了