在阅读此篇之前,请先看完第11篇的一小节,传送门:11-服务端进程终止和SIGPIPE信号
1. 服务器进程终止的问题
在第11篇中的第1小节中,tcp客户端需要同时处理两个输入:标准输入和tcp套接字,tcp客户端代码如下所示:
//循环读写数据
while (1) {
//tcp客户端阻塞于fgets上
fgets(buf, sizeof(buf), stdin);
write(sfd, buf, strlen(buf));
//此时read到EOF
len = read(sfd, buf, sizeof(buf));
if(len == 0){
puts("peer close");
break;
}else if(len < 0){
perror("read error: ");
}
write(STDOUT_FILENO, buf, len);
}
对于上面的代码是存在问题的,服务器tcp虽然正确的发送了FIN,但由于tcp客户端阻塞在标准输入(fget调用)上等待读取数据,这会导致tcp客户端无法及时看到这个EOF,直到tcp客户端调用read读取套接字才会看到这个EOF,tcp客户端什么时候看到这个EOF是不确定的,这取决于tcp客户端在fgets调用处阻塞的时间长短。
因此我们希望当服务器关闭tcp连接发送FIN时,tcp客户端可以马上检测到对端tcp连接已关闭,从而作出相应的处理动作,而能帮助客户端实现这种能力的就是I/O多路复用技术。
除了以上这种情况,还有其他使用I/O多路复用技术的场景,这里主要讨论在网络中的应用:
1. 客户端同时处理多个套接字(客户端确实是有可能有这种需求的)
2. 服务端既要监听套接字,又要处理已连接的套接字
3. 服务端需要处理多个协议,既要处理tcp,又要处理udp
2. 文件描述符何时就绪
select就是一种多路IO复用技术,根据之前我们学习可知,select会检测监听的文件描述符是否有事件发生(有数据可读或可写),因此我们需要了解文件描述符什么时候准备好,但是对于不同的文件类型,文件描述符就绪的条件也不一样,这里我们主要讨论套接字文件描述符就绪的条件。
对于读操作,文件描述符就绪条件:
- tcp连接的对端关闭(半关闭),此时对套接字进行读操作将返回EOF
- 监听的套接字上有新的已完成“三次握手”的tcp连接
- 套接字发生了异常或错误,对这样的套接字进行读操作将会返回errno错误,也可以通过SO_ERROR套接字选项或getsockop清除。
对于写操作,文件描述符就绪条件:
- tcp连接的对端关闭(半关闭),此时对套接字进行写操作将会收到SIGPIPE信号
- 使用非阻塞connect建立tcp连接成功或失败
- 套接字发生了异常或错误,对这样的套接字进行写操作将会返回errno错误
下面将通过select重写客户端实验,进一步验证select检测文件描述符就绪的条件。
3. 使用select重写客户端
原来的tcp客户端是阻塞与fgets调用处,使用select重写tcp客户端后,改为阻塞于select调用处,下面是改写后的tcp客户端程序:
fd_set set;
//循环读写数据
while (1) {
FD_ZERO(&set);
//设置套接字,标准输入文件描述符
FD_SET(sfd , &set);
FD_SET(STDIN_FILENO , &set);
int maxfd = sfd + 1;
//检测文件描述符
select(maxfd , &set , NULL , NULL , NULL);
//套接字文件描述符是否有事件发生
if(FD_ISSET(sfd , &set)){
//通过select改写后,如果对端关闭会立即检测到
len = read(sfd , buf , sizeof(buf));
if(len == 0){
puts("peer close");
break;
}else if(len < 0){
perror("read error: ");
}
write(STDOUT_FILENO, buf, len);
}
//标准输入是否有事件发生
if(FD_ISSET(STDIN_FILENO , &set)){
fgets(buf , sizeof(buf) , stdin);
write(sfd , buf , strlen(buf));
}
}
//关闭连接
close(sfd);
先运行./server,然后再运行./client
通过kill命令把服务端子进程终止掉,然后服务端子进程终止时会发送FIN:
通过select改写后,根据之前的文件描述符就绪条件,当服务器这端的tcp连接关闭时(调用close关闭后,服务端通常会发送一个文件结束标记EOF,然后才发送FIN关闭tcp连接),select就会检测到套接字文件描述符发生了事件,接着客户端调用read读取套接字就会读到EOF标志并返回0,然后关闭连接,从tcpdump工具抓到的数据包来看,这是一个正常的tcp连接释放过程:
4. tcp优雅关闭
这里简单提一下,调用close跟tcp连接释放实际上是没有什么关系的,因为close是属于文件系统的一个系统调用,用于关闭文件描述符的,并保证没有进程会读取,当有多个进程引用套接字时(引用计数大于1),调用close不会发生tcp连接释放,因为只有当引用计数为0时,才会触发tcp连接关闭。
对于shutdown调用来说,它才是专门用于关闭一个tcp连接的,shutdown没有文件系统的语义,它是专门针对内核的tcp套接字的,因此调用shutdown才是真正关闭了通信的套接字。
重点来了!!!注意体会
再说回UNP中所说的优雅关闭,所谓的优雅关闭实则是调用close之前,先调用shutdown,这样的顺序才是关闭tcp连接的正确方式,只有调用shutdown(SHUT_WR)才会发送一个FIN。
5. shutdown函数
再次强调一下close关闭网络连接的问题:
由于调用close关闭套接字会终止两个方向的数据传输,但是tcp是支持双向通信的,有时候我们只想关闭一端的数据传输,比如客户端没有数据要发送了,就调用shutdown关闭了写端,另一端仍然可以继续发送数据,如下图所示:
图1-调用shutdown关闭tcp连接
shutdown函数原型:
#include <sys/socket.h>
int shutdown(int sockfd, int how);
参数sockfd用于指定socket的描述符
shutdown函数可选择中止一个方向的连接(不用考虑描述符的引用计数),而参数how是指定关闭哪一方向的tcp连接。
SHUT_RD:关闭连接的读端,即套接字不再接收数据,并且该套接字的接收缓冲区已有的数据会丢弃,也就是说进程不能再对该套接字进行读操作了,但是可以对套接字进行写操作
UT_WR:关闭连接的写端,即半关闭(half-close),进程不能对套接字进行写操作,并且套接字发送缓冲区中剩下的数据将会发送完,但是进程依然可以对套接字进行读操作。
SHUT_RDWR:相当于关闭了连接的读写两端,不能再对该套接字进行读写操作。
简单提一下:套接字本质上是由内核维护的一个伪文件,该文件内部有2个缓冲区,一个读缓冲区,一个写缓冲区。因此在调用shutdown函数传递SHUT_RD参数表示不能对该套接字的读缓冲区读取数据,但是依然可以对缓冲区写数据,SHUT_WR参数表示不能对该套接字的写缓冲区写数据,但是依然可以对读缓冲区读数据。
当客户端和服务端建立tcp连接后,服务端不会主动断开连接,一般是由客户端主动发起关闭连接,所以我们对客户端进行改写,在调用close之前,调用shutdown指定SHUT_WR:
fgets(buf, sizeof(buf), stdin);
//将数据写给服务器
write(sfd, buf, strlen(buf));
//关闭写端,指定SHUT_WR后给会对方发送一个FIN
shutdown(sfd , SHUT_WR);
//依然可以读
len = read(sfd, buf, sizeof(buf));
write(STDOUT_FILENO, buf, len);
//最后关闭套接字
close(sfd);
return 0;
启动客户端和服务端:
tcpdump抓取到的数据报如下:
第5个数据报是客户端调用shutdown关闭写端后发送的FIN,第6个是对数据和FIN的确认,第7和第8两个是服务端回写的数据包和发送的FIN,客户端收到后回复了确认,其中第10个是对FIN的确认。