在 UNIX 环境下的 TCP 套接字编程中,连接的"关闭"并非简单的"断开"操作------不同场景下需要选择合适的关闭方式,以避免数据丢失、连接残留等问题。核心用于关闭套接字的函数有两个:close
和 shutdown
。本文的内容,详细解析这两个函数的功能、参数与使用方法,对比其核心区别与适用场景,并通过实例演示如何正确使用它们处理 TCP 连接关闭。
一、TCP 套接字关闭核心函数解析
TCP 是面向连接的协议,套接字的关闭需要遵循"优雅关闭"或"强制关闭"的逻辑。close
函数基于"引用计数"实现,而 shutdown
函数基于"方向控制"实现,二者的设计理念和使用场景截然不同。
1.1 close 函数:基于引用计数的套接字关闭
close
函数是 UNIX 中通用的文件描述符关闭函数,同样适用于套接字------它的核心逻辑是"减少套接字的引用计数",仅当引用计数降至 0 时,才真正关闭 TCP 连接。
1.1.1 函数原型
#include <unistd.h>
int close(int sockfd);
1.1.2 参数说明
参数 | 功能 | 使用注意 |
---|---|---|
sockfd |
需要关闭的套接字描述符(由 socket 或 accept 函数返回) |
必须是当前进程持有的有效套接字描述符,否则会返回错误 |
1.1.3 核心机制:引用计数
UNIX 中的套接字描述符采用"引用计数"管理------当一个套接字被创建后(如 socket
创建客户端套接字,accept
创建服务器端连接套接字),其引用计数初始化为 1。当多个进程或线程共享该套接字(如 fork
子进程继承父进程的套接字)时,引用计数会相应增加。
close
函数的作用是将该套接字的引用计数减 1:
- 若引用计数减 1 后大于 0:仅释放当前进程对套接字的引用,TCP 连接仍保持(其他持有该套接字的进程可继续使用);
- 若引用计数减 1 后等于 0:彻底关闭 TCP 连接(发送 FIN 数据包,触发 TCP 四次挥手),释放套接字占用的内核资源。
关键结论 :close
函数关闭的是"进程与套接字的关联",而非直接关闭"TCP 连接"。只有当所有持有该套接字的进程都调用 close
后,连接才会真正关闭。
1.1.4 返回值
-
成功:返回 0(表示套接字描述符已释放,引用计数已更新);
-
失败:返回 -1,并设置
errno
(如EBADF
表示套接字描述符无效,EINTR
表示调用被信号中断)。
1.2 shutdown 函数:基于方向控制的强制关闭
shutdown
函数是专为套接字设计的关闭函数------它不依赖引用计数,而是直接对 TCP 连接的"读方向"或"写方向"进行强制关闭,甚至可以同时关闭两个方向。这种"半关闭"能力是 shutdown
与 close
的核心区别。
1.2.1 函数原型
#include <sys/socket.h>
int shutdown(int s, int how);
1.2.2 参数说明
参数 | 功能 | 常用取值与含义 |
---|---|---|
s |
需要操作的套接字描述符 | 与 close 函数的 sockfd 一致,需为有效套接字描述符 |
how |
指定关闭的方向,决定对 TCP 连接的操作范围 | * SHUT_RD :关闭"读方向"------当前进程无法再从套接字接收数据(即使对方发送数据,内核也会丢弃),后续调用 read/recv 会返回 0(表示EOF); * SHUT_WR :关闭"写方向"------当前进程无法再向套接字发送数据(后续调用 write/send 会返回错误),内核会向对方发送 FIN 数据包(表示"数据已发送完毕"); * SHUT_RDWR :同时关闭"读方向"和"写方向"------等效于先调用 SHUT_RD 再调用 SHUT_WR ,彻底切断当前进程与对方的双向通信。 |
1.2.3 核心特性:不依赖引用计数
shutdown
函数的作用对象是"TCP 连接"本身,而非"套接字描述符的引用"------无论套接字的引用计数是否为 0,只要调用 shutdown
,就会立即对指定方向的连接进行关闭:
- 即使其他进程仍持有该套接字的引用,
shutdown
关闭的方向对所有进程都生效(如进程 A 调用shutdown(s, SHUT_WR)
后,进程 B 也无法再通过该套接字发送数据); shutdown
不会改变套接字的引用计数,调用后仍需通过close
释放套接字描述符(避免资源泄漏)。
核心优势 :shutdown
支持"半关闭"(如 SHUT_WR
关闭写方向但保留读方向),适用于"发送完数据后,需等待对方确认"的场景(如 HTTP 响应发送后,等待客户端确认接收)。
1.2.4 返回值
-
成功:返回 0(表示指定方向的连接已关闭);
-
失败:返回 -1,并设置
errno
(如EBADF
表示套接字无效,EINVAL
表示how
参数取值错误)。
二、close 与 shutdown 函数的核心区别对比
为了更清晰地理解两个函数的差异,下表从"核心机制""连接影响""方向控制"等维度进行对比:
对比维度 | close 函数 | shutdown 函数 |
---|---|---|
核心机制 | 基于"引用计数"------减少套接字描述符的引用计数 | 基于"方向控制"------直接操作 TCP 连接的读/写方向 |
对 TCP 连接的影响 | 仅当引用计数降至 0 时,才关闭连接(发送 FIN) | 无论引用计数如何,立即关闭指定方向的连接(发送 FIN 或切断接收) |
方向控制能力 | 无------只能"全关闭"(若连接关闭,读写方向均切断) | 有------支持 SHUT_RD (读)、SHUT_WR (写)、SHUT_RDWR (读写) |
多进程共享场景 | 单个进程调用后,其他进程仍可使用套接字(引用计数未到 0) | 单个进程调用后,所有进程均受影响(如关闭写方向后,其他进程也无法发送数据) |
资源释放 | 引用计数到 0 时,释放套接字内核资源 | 不释放套接字资源,仅关闭连接方向,需后续调用 close 释放描述符 |
适用场景 | 普通场景下的套接字释放(如单进程连接,无需半关闭) | 需要半关闭、强制关闭或明确控制读写方向的场景(如等待对方确认数据) |
三、函数使用实例演示
对套接字编程的实践思路,以下通过两个实例分别演示 close
和 shutdown
函数的使用,覆盖"多进程共享套接字"和"半关闭连接"两个典型场景。
3.1 实例1:close 函数与多进程共享套接字
场景:父进程通过 fork
创建子进程,父子进程共享同一个 TCP 连接套接字。演示"仅父进程调用 close
时,连接不关闭;子进程也调用 close
后,连接才关闭"的逻辑。
3.1.1 完整代码
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 9001
// 错误处理宏
#define ERROR_CHECK(ret, msg) \
if (ret == -1) { \
perror(msg); \
exit(EXIT_FAILURE); \
}
int main() {
int sockfd;
struct sockaddr_in serv_addr;
pid_t pid;
// 步骤1:创建TCP套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
ERROR_CHECK(sockfd, "socket() failed");
printf("创建套接字成功,sockfd = %d\n", sockfd);
// 步骤2:初始化服务器地址并连接
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr(SERVER_IP);
serv_addr.sin_port = htons(SERVER_PORT);
int conn_ret = connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
ERROR_CHECK(conn_ret, "connect() failed");
printf("与服务器 %s:%d 连接成功\n", SERVER_IP, SERVER_PORT);
// 步骤3:fork子进程,共享sockfd
pid = fork();
ERROR_CHECK(pid, "fork() failed");
if (pid == 0) {
// 子进程:睡眠5秒后关闭套接字
printf("子进程(PID: %d):将在5秒后调用close()\n", getpid());
sleep(5);
int close_ret = close(sockfd);
ERROR_CHECK(close_ret, "child close() failed");
printf("子进程(PID: %d):调用close()成功,释放套接字引用\n", getpid());
// 子进程继续运行3秒,模拟其他操作
sleep(3);
printf("子进程(PID: %d):退出\n", getpid());
exit(EXIT_SUCCESS);
} else {
// 父进程:立即调用close()
printf("父进程(PID: %d):立即调用close()\n", getpid());
int close_ret = close(sockfd);
ERROR_CHECK(close_ret, "parent close() failed");
printf("父进程(PID: %d):调用close()成功,释放套接字引用\n", getpid());
// 父进程等待子进程退出,避免僵死进程
wait(NULL);
printf("父进程(PID: %d):子进程已退出,连接此时才真正关闭\n", getpid());
}
return 0;
}
3.1.2 代码说明与运行结果
代码逻辑:
- 父进程创建套接字并连接服务器,此时套接字引用计数为 1;
fork
子进程后,子进程继承套接字,引用计数变为 2;- 父进程立即调用
close
,引用计数减为 1(连接未关闭,子进程仍可使用); - 子进程睡眠 5 秒后调用
close
,引用计数减为 0(连接真正关闭,触发四次挥手)。
运行结果(需先启动监听 9001 端口的 TCP 服务器):
创建套接字成功,sockfd = 3
与服务器 127.0.0.1:9001 连接成功
父进程(PID: 1234):立即调用close()
父进程(PID: 1234):调用close()成功,释放套接字引用
子进程(PID: 1235):将在5秒后调用close()
子进程(PID: 1235):调用close()成功,释放套接字引用
子进程(PID: 1235):退出
父进程(PID: 1234):子进程已退出,连接此时才真正关闭
现象验证 :可通过 netstat -an | grep 9001
命令观察连接状态------父进程调用 close
后,连接仍处于 ESTABLISHED
(已建立)状态;子进程调用 close
后,连接才变为 TIME_WAIT
(关闭过程中)。
3.2 实例2:shutdown 函数与半关闭连接
场景:TCP 客户端向服务器发送完数据后,需要"关闭写方向"(告知服务器"数据已发送完毕"),但仍需"保留读方向"(等待服务器返回确认信息)。此时 shutdown(SHUT_WR)
是最佳选择。
3.2.1 完整代码
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 9001
#define BUF_SIZE 1024
#define ERROR_CHECK(ret, msg) \
if (ret == -1) { \
perror(msg); \
exit(EXIT_FAILURE); \
}
int main() {
int sockfd;
struct sockaddr_in serv_addr;
char send_buf[BUF_SIZE] = "Hello Server! This is client data.";
char recv_buf[BUF_SIZE] = {0};
int ret;
// 步骤1:创建并连接套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
ERROR_CHECK(sockfd, "socket() failed");
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr(SERVER_IP);
serv_addr.sin_port = htons(SERVER_PORT);
ret = connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
ERROR_CHECK(ret, "connect() failed");
printf("与服务器 %s:%d 连接成功\n", SERVER_IP, SERVER_PORT);
// 步骤2:向服务器发送数据
ret = send(sockfd, send_buf, strlen(send_buf), 0);
ERROR_CHECK(ret, "send() failed");
printf("发送数据:%s(长度:%d 字节)\n", send_buf, ret);
// 步骤3:调用shutdown关闭写方向(半关闭),保留读方向
ret = shutdown(sockfd, SHUT_WR);
ERROR_CHECK(ret, "shutdown(SHUT_WR) failed");
printf("已关闭写方向:无法再发送数据,但可接收服务器响应\n");
// 步骤4:尝试发送数据(应失败)
ret = send(sockfd, "This is a test after shutdown", 29, 0);
if (ret == -1) {
printf("尝试发送数据失败(符合预期):%s\n", strerror(errno));
}
// 步骤5:接收服务器的确认响应(读方向仍有效)
ret = recv(sockfd, recv_buf, BUF_SIZE, 0);
if (ret > 0) {
printf("收到服务器响应:%s(长度:%d 字节)\n", recv_buf, ret);
} else if (ret == 0) {
printf("服务器已关闭连接(收到EOF)\n");
} else {
perror("recv() failed");
}
// 步骤6:关闭套接字(释放描述符)
ret = close(sockfd);
ERROR_CHECK(ret, "close() failed");
printf("已关闭套接字,释放资源\n");
return 0;
}
3.2.2 代码说明与运行结果
代码逻辑:
- 客户端连接服务器后,发送数据给服务器;
- 调用
shutdown(sockfd, SHUT_WR)
关闭写方向------此时客户端无法再发送数据,但可接收服务器的响应; - 尝试再次发送数据,验证写方向已关闭(应返回错误);
- 接收服务器的确认响应(读方向有效);
- 调用
close
释放套接字描述符。
运行结果(服务器需在收到数据后返回确认信息,如"Data received successfully"):
与服务器 127.0.0.1:9001 连接成功
发送数据:Hello Server! This is client data.(长度:35 字节)
已关闭写方向:无法再发送数据,但可接收服务器响应
尝试发送数据失败(符合预期):Bad file descriptor
收到服务器响应:Data received successfully(长度:26 字节)
已关闭套接字,释放资源
半关闭价值 :在需要"先发送完数据,再等待对方确认"的场景(如 HTTP 请求、文件传输),SHUT_WR
可以明确告知对方"数据已结束",同时避免因直接关闭连接导致无法接收确认信息。
四、常见错误与解决方案
在使用 close
和 shutdown
函数时,容易因对其机制理解不透彻导致错误。下表整理了常见问题、原因及解决方法:
常见错误 | 错误原因 | 解决方案 |
---|---|---|
调用 close 后,其他进程仍能使用套接字,误以为"连接已关闭" |
未理解 close 的引用计数机制------单个进程调用 close 仅释放自身引用,未改变其他进程的引用 |
1. 若需立即关闭连接,改用 shutdown(SHUT_RDWR) ; 2. 多进程场景下,确保所有共享套接字的进程都调用 close |
调用 close 后,仍有未接收的数据丢失 |
close 关闭连接时,内核会丢弃套接字接收缓冲区中未读取的数据 |
1. 关闭前先调用 read/recv 读取剩余数据(直到返回 0 或错误); 2. 若需保留读数据,先调用 shutdown(SHUT_WR) ,读取完数据后再 close |
shutdown 后未调用 close ,导致套接字描述符泄漏 |
shutdown 仅关闭连接方向,不释放套接字描述符------进程退出前未 close 会导致描述符泄漏 |
养成"shutdown 后必 close "的习惯:无论 shutdown 是否成功,最终都需调用 close 释放描述符 |
shutdown 参数 how 取值错误(如传入 4),导致功能异常 |
对 how 参数的取值范围不了解------仅支持 SHUT_RD 、SHUT_WR 、SHUT_RDWR 三个宏定义 |
1. 严格使用系统定义的宏(避免直接传入数字); 2. 包含头文件 <sys/socket.h> ,确保宏定义可见 |
多线程场景下,多个线程同时调用 close 同一套接字,导致错误 |
套接字描述符是进程级资源,多个线程同时 close 会导致引用计数异常(如重复减 1 至负数) |
1. 对套接字操作加锁(如互斥锁),确保同一时间只有一个线程调用 close ; 2. 设计线程职责,由单个线程负责套接字的关闭 |
五、拓展:TCP 套接字关闭后的状态与优化
TCP 连接关闭并非"即时完成",而是需要经过"四次挥手"过程,期间会处于不同的状态(如 TIME_WAIT
、CLOSE_WAIT
)。这些状态若处理不当,可能导致端口耗尽、连接残留等问题。
5.1 常见关闭状态解析
状态名称 | 产生场景 | 持续时间 | 作用 |
---|---|---|---|
TIME_WAIT (时间等待) |
主动关闭连接的一方(发送 FIN 后,收到对方 ACK 和 FIN,发送最后一个 ACK 后进入) | 通常为 2*MSL(MSL 是报文最大生存时间,默认 30 秒,即 TIME_WAIT 持续 60 秒) | 1. 确保最后一个 ACK 被对方接收(若对方未收到,会重发 FIN,TIME_WAIT 期间可再次发送 ACK); 2. 避免旧连接的数据包干扰新连接(等待旧数据包超时消失) |
CLOSE_WAIT (关闭等待) |
被动关闭连接的一方(收到对方 FIN 后,发送 ACK 后进入,此时应用层未调用 close/shutdown ) |
无固定时间,取决于应用层何时关闭套接字 | 表示"已收到对方关闭请求,等待应用层关闭套接字" |
5.2 状态优化方案
5.2.1 TIME_WAIT 状态优化
TIME_WAIT
状态的连接会占用端口(默认 60 秒),若短时间内大量创建并关闭连接(如高并发服务器),可能导致"端口耗尽"。优化方案:
-
启用端口复用 :通过
setsockopt
函数设置SO_REUSEADDR
选项,允许新连接复用处于TIME_WAIT
状态的端口。
示例代码:int reuse = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
-
缩短 TIME_WAIT 时间 :在 Linux 系统中,可通过修改内核参数
net.ipv4.tcp_fin_timeout
缩短 MSL 时间(如设为 30 秒,TIME_WAIT 变为 60 秒 → 30 秒),但需谨慎操作(可能增加旧数据包干扰风险)。
5.2.2 CLOSE_WAIT 状态优化
CLOSE_WAIT
状态的产生本质是"应用层未及时关闭套接字"(如程序bug导致未调用 close/shutdown
)。解决方案:
- 确保套接字关闭逻辑完整 :在任何代码分支(正常流程、错误处理、信号中断)中,都需调用
close
或shutdown
关闭套接字; - 监控 CLOSE_WAIT 连接数 :通过
netstat -an | grep CLOSE_WAIT | wc -l
定期检查,若数量异常增长,排查代码中是否存在套接字泄漏。
六、总结:如何选择 close 与 shutdown 函数?
在实际 TCP 套接字编程中,选择哪个函数关闭连接,需根据具体场景判断:
6.1 优先选择 close 函数的场景
- 单进程使用套接字,无需半关闭或强制关闭------如简单的客户端连接,发送完数据后直接关闭;
- 仅需释放当前进程对套接字的引用,不影响其他进程------如多进程共享套接字时,单个进程退出前释放描述符;
- 普通场景下的"正常关闭"------无需控制读写方向,仅需确保连接最终关闭。
6.2 优先选择 shutdown 函数的场景
- 需要半关闭连接------如发送完数据后,需等待对方确认(关闭写方向,保留读方向);
- 需要强制关闭连接------如多进程共享套接字时,需立即切断连接(无论其他进程是否释放引用);
- 明确控制读写方向------如仅关闭读方向(避免接收无效数据),或仅关闭写方向(告知对方数据已结束);
- 异常场景下的紧急关闭------如检测到对方异常,需立即切断双向通信(调用
shutdown(s, SHUT_RDWR)
)。
最终建议 :shutdown
函数的功能更灵活,但使用后仍需调用 close
释放套接字描述符;close
函数更简单,但需注意引用计数机制的影响。在不确定的场景下,可优先使用"shutdown
控制方向 + close
释放资源"的组合,确保连接安全关闭。