UNIX网络编程笔记:基本TCP套接字编程
一、socket函数
一、socket函数核心参数与协议组合
-
函数原型与基本功能
#include <sys/socket.h> int socket(int family, int type, int protocol);
• 功能:创建通信端点(套接字),返回描述符供后续操作。
• 返回值:成功返回非负描述符,失败返回-1
,错误码存于errno
。 -
核心参数详解
参数 可选值及说明 family
AF_INET
(IPv4)、AF_INET6
(IPv6)、AF_LOCAL
(本地通信)、AF_ROUTE
(路由操作)、AF_KEY
(IPsec密钥管理)type
SOCK_STREAM
(TCP流式)、SOCK_DGRAM
(UDP数据报)、SOCK_RAW
(原始套接字)、SOCK_SEQPACKET
(有序分组如SCTP)protocol
通常设为 0
(自动选择默认协议),或显式指定如IPPROTO_TCP
、IPPROTO_UDP
。 -
有效参数组合与默认协议
family type 默认协议 典型应用 AF_INET
SOCK_STREAM
TCP ( IPPROTO_TCP
)HTTP、SSH等可靠传输场景 AF_INET
SOCK_DGRAM
UDP ( IPPROTO_UDP
)DNS查询、实时音视频传输 AF_INET6
SOCK_STREAM
TCP 支持IPv6的服务器开发 AF_LOCAL
SOCK_STREAM
Unix域字节流 本地进程间高性能通信(无需网络协议栈) AF_ROUTE
SOCK_RAW
路由表操作 网络监控工具(如 netstat
)AF_KEY
SOCK_RAW
IPsec密钥管理 VPN或加密通信配置 -
特殊类型与协议
• 原始套接字(SOCK_RAW
)
◦ 用途:构造自定义IP数据包(如实现Ping工具、嗅探器)。
◦ 权限要求:需root权限(Linux)或管理员权限(Windows)。
• SCTP与SOCK_SEQPACKET
◦ 支持多宿主、消息边界保留,适用于实时性要求高的场景(如VoIP)。
◦ 需安装lksctp-tools
库(Linux)。
二、TCP客户端/服务器编程流程
-
服务器端流程
socket() → bind() → listen() → accept() → read()/write() → close()
• 关键步骤:
◦bind()
:绑定IP和端口(INADDR_ANY
表示监听所有接口)。
◦listen()
:设置监听队列长度(backlog
参数)。
◦accept()
:阻塞直到客户端连接,返回 已连接套接字(与监听套接字分离)。 -
客户端流程
socket() → connect() → write()/read() → close()
• 关键步骤:
◦connect()
:触发TCP三次握手,建立连接。
◦ 数据交换:通过read()
/write()
或send()
/recv()
进行双向通信。 -
TCP与UDP对比
特性 TCP UDP 连接方式 面向连接(可靠传输) 无连接(尽力而为) 数据边界 字节流(无固定边界) 数据报(保留边界) 适用场景 文件传输、Web请求 实时游戏、广播
三、关键注意事项与最佳实践
-
协议兼容性
• 避免无效组合(如SOCK_DGRAM
+IPPROTO_TCP
),错误码为EPROTONOSUPPORT
。
•AF_LOCAL
和AF_UNIX
等价,但建议使用POSIX标准的AF_LOCAL
。 -
系统依赖性与权限
•AF_ROUTE
和AF_KEY
仅在类Unix系统(如Linux、BSD)中可用。
• 原始套接字需特权权限,且可能被防火墙拦截。 -
开发规范
• 头文件:使用<sys/socket.h>
和<netinet/in.h>
,而非C++头文件。
• 错误处理:检查所有系统调用返回值,处理EINTR
(信号中断)等错误。 -
性能优化
• 设置SO_REUSEADDR
选项避免bind()
失败(TIME_WAIT
状态残留)。
• 使用select()
/poll()
/epoll()
实现多路复用,提升高并发性能。
四、代码示例(TCP服务器)
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0); // 创建IPv4 TCP套接字
struct sockaddr_in serv_addr = {0};
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有接口
serv_addr.sin_port = htons(8080); // 绑定端口8080
bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
listen(listen_fd, 10); // 设置backlog为10
printf("Server listening on port 8080...\n");
int conn_fd = accept(listen_fd, NULL, NULL); // 接受客户端连接
char buffer[1024];
read(conn_fd, buffer, sizeof(buffer)); // 读取客户端数据
write(conn_fd, "Hello from server!", 18); // 发送响应
close(conn_fd);
close(listen_fd);
return 0;
}
二、connect函数
一、connect
函数基本定义与作用
-
函数原型与参数
#include <sys/socket.h> int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
• 参数解析:
◦sockfd
:客户端套接字描述符(由socket()
创建)。
◦servaddr
:指向服务器地址结构的指针(需包含IP和端口)。
◦addrlen
:地址结构长度(通常为sizeof(struct sockaddr_in)
)。
• 返回值:成功返回0
,失败返回-1
,错误码存于errno
。 -
核心功能
• 触发TCP三次握手:向服务器发送SYN
段,建立连接。
• 阻塞与非阻塞模式:
◦ 阻塞模式:函数阻塞直到连接成功或失败(默认行为)。
◦ 非阻塞模式:立即返回-1
,错误码为EINPROGRESS
,需后续通过select
/poll
检查状态。
二、connect
函数的错误类型与处理
1. 立即失败错误(Hard Error)
• 触发条件:服务器明确拒绝连接(如目标端口无监听服务)。
• 错误表现:
• 服务器返回RST
(Reset)分段(表示端口关闭或服务未启动)。
• 客户端收到RST
后,connect
返回错误码 ECONNREFUSED
。
• 典型场景:
// 示例:连接未开启的端口(如80端口无HTTP服务)
connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
if (errno == ECONNREFUSED) {
perror("Connection refused: No service listening on port");
}
2. 软错误(Soft Error)
• 触发条件:网络路径不可达(如路由问题、防火墙拦截)。
• 错误表现:
• 中间路由器返回 ICMP“目的地不可达” 消息。
• 客户端等待重试超时后,connect
返回错误码 EHOSTUNREACH
或 ENETUNREACH
。
• 处理逻辑:
// 示例:目标网络不可达
connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
if (errno == EHOSTUNREACH || errno == ENETUNREACH) {
perror("Network unreachable: Check routing or firewall");
}
3. 超时错误(Timeout Error)
• 触发条件:服务器未响应SYN
段(如服务器宕机、网络丢包)。
• 错误表现:
• 客户端内核多次重发SYN
段(通常间隔为3秒、6秒、12秒等)。
• 总等待时间约为 75秒(因系统而异),最终返回错误码 ETIMEDOUT
。
• 典型场景:
// 示例:服务器宕机或网络完全中断
connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
if (errno == ETIMEDOUT) {
perror("Connection timed out: Server not responding");
}
三、connect
函数的底层行为与系统依赖
-
TCP三次握手过程
• 步骤:- 客户端发送
SYN
段(序列号随机生成,如x
)。 - 服务器回复
SYN-ACK
段(序列号y
,确认号x+1
)。 - 客户端发送
ACK
段(确认号y+1
),连接建立。
• 内核自动完成:无需应用层干预。
- 客户端发送
-
系统依赖的细节
• 重试次数与超时:不同系统(如BSD、Linux)的重试策略不同。
◦ BSD系统:初始等待3秒,后续重试间隔指数级增长。
◦ Linux:默认总超时约为120秒(可通过sysctl
调整)。
• ICMP错误处理:
◦ 某些系统(如早期BSD)可能忽略ICMP错误,导致超时而非立即失败。
四、关键注意事项与最佳实践
-
错误处理优先级
• 优先检查ECONNREFUSED
(明确的服务不可用)。
• 次优处理ETIMEDOUT
(网络或服务器问题)。
• 最后处理EHOSTUNREACH
(网络配置问题)。 -
非阻塞模式下的处理
• 使用select
/poll
等待套接字可写。
• 通过getsockopt
检查SO_ERROR
选项确认最终状态。// 非阻塞模式示例 int flags = fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); if (connect(sockfd, ...) == -1) { if (errno == EINPROGRESS) { // 等待套接字可写 fd_set writefds; FD_ZERO(&writefds); FD_SET(sockfd, &writefds); struct timeval tv = {5, 0}; // 5秒超时 select(sockfd+1, NULL, &writefds, NULL, &tv); if (FD_ISSET(sockfd, &writefds)) { int error; socklen_t len = sizeof(error); getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len); if (error != 0) { // 处理具体错误 } } } }
-
避免常见陷阱
• 地址复用:设置SO_REUSEADDR
选项避免bind
冲突。
• DNS解析:在connect
前完成主机名解析(使用getaddrinfo
)。
• 防火墙规则:确保客户端出站端口和服务器入站端口开放。
五、总结
• connect
函数是TCP客户端建立连接的核心步骤,其行为受网络状态、服务器响应及系统配置影响。
• 错误类型分为硬错误(立即失败)、软错误(网络不可达)和超时错误(无响应),需针对性处理。
• 实际开发中:需结合非阻塞I/O和多路复用技术,并严格处理所有可能的错误码以增强健壮性。
三、bind函数
一、bind
函数核心机制
1. 函数定义与参数
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
• 参数解析:
• addr
:指向协议特定地址结构的指针(如struct sockaddr_in
或struct sockaddr_in6
)
• addrlen
:地址结构的实际长度(例如sizeof(struct sockaddr_in)
)
• 返回值:
• 成功返回0
,失败返回-1
并设置errno
2. 绑定目标类型
绑定内容 | 设置字段 | 典型场景 |
---|---|---|
指定IP + 指定端口 | sin_addr 和sin_port 均明确赋值 | Web服务器绑定0.0.0.0:80 |
仅指定IP | sin_addr 赋值,sin_port=0 | 多网卡服务器限定接收接口 |
仅指定端口 | sin_addr=INADDR_ANY ,sin_port 赋值 | FTP服务器绑定:21 |
不指定IP和端口 | INADDR_ANY 且sin_port=0 | 内核自动分配临时地址(客户端常见) |
二、TCP客户端与服务器的绑定差异
1. 端口绑定规则
角色 | 默认行为 | 显式绑定场景 |
---|---|---|
TCP客户端 | connect() 时内核分配临时端口 | 需要预留端口(如特权端口<1024) |
TCP服务器 | 必须绑定知名端口(如HTTP的80) | 多服务实例需绑定不同端口 |
RPC服务器 | 内核分配临时端口,通过端口映射器注册 | 动态服务发现场景 |
2. IP绑定规则
角色 | IP绑定作用 | 内核行为补充 |
---|---|---|
TCP客户端 | 指定发送数据包的源IP地址 | 未绑定时,内核根据路由表选择出口IP |
TCP服务器 | 限制只接收目标为该IP的连接请求 | 绑定INADDR_ANY 时接收所有接口请求 |
三、内核自动处理逻辑
1. 临时端口分配流程
// 客户端不调用bind时的内核行为示例
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
// 内核自动完成以下操作:
// 1. 选择临时端口(范围:32768~60999,见/proc/sys/net/ipv4/ip_local_port_range)
// 2. 根据目标地址选择源IP
2. 未绑定IP时的内核决策
• TCP服务器:
• 绑定INADDR_ANY
时,接收所有网络接口的连接请求
• 示例:双网卡服务器(eth0:192.168.1.100
, eth1:10.0.0.100
)可同时处理两个子网的连接
• TCP客户端:
• 内核根据路由表选择最优出口IP(如访问公网时选择公网IP,访问内网时选择内网IP)
四、RPC服务器的特殊处理
1. **动态端口注册流程
- 内核分配临时端口:
bind(sockfd, INADDR_ANY, 0); // 端口=0触发内核分配 getsockname(sockfd, &addr, &addrlen); // 获取实际端口号
- 向RPC端口映射器注册:
• 通过rpcbind
服务(端口111)注册(program, version)
与端口号的映射关系 - 客户端查询流程:
• 客户端先查询端口映射器获取实际端口
• 再向目标端口发起连接
五、关键代码示例
1. TCP服务器绑定所有接口的80端口
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定所有IP
servaddr.sin_port = htons(80); // 绑定80端口
if (bind(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) == -1) {
perror("bind failed");
exit(EXIT_FAILURE);
}
2. 显式绑定客户端源IP
struct sockaddr_in client_addr;
memset(&client_addr, 0, sizeof(client_addr));
client_addr.sin_family = AF_INET;
inet_pton(AF_INET, "192.168.1.100", &client_addr.sin_addr); // 指定源IP
client_addr.sin_port = htons(0); // 内核选端口
bind(sockfd, (struct sockaddr*)&client_addr, sizeof(client_addr));
connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
六、常见问题与调试
1. 绑定失败原因
错误码 | 原因 | 解决方案 |
---|---|---|
EADDRINUSE | 端口已被其他进程占用 | netstat -tuln 查找占用进程 |
EACCES | 尝试绑定特权端口(<1024)无root权限 | 使用sudo 或以CAP_NET_BIND_SERVICE 能力运行 |
EINVAL | 套接字已绑定过地址 | 检查是否重复调用bind |
2. 验证绑定结果
# 查看已绑定端口
ss -tuln sport = :80
# 输出示例
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port
tcp LISTEN 0 128 *:80 *:*
七、设计建议
- 服务器绑定策略:
• 生产环境优先绑定INADDR_ANY
,通过防火墙规则限制访问IP
• 开发环境可绑定127.0.0.1
防止外部访问 - 客户端绑定慎用:
• 除非需要固定源IP或端口,否则依赖内核自动选择 - IPv6兼容性:
• 使用getaddrinfo()
自动处理双协议栈地址struct addrinfo hints = {0}; hints.ai_family = AF_UNSPEC; // 支持IPv4/IPv6 hints.ai_socktype = SOCK_STREAM; getaddrinfo("example.com", "http", &hints, &res);
🌐 八、地址绑定核心概念
1. bind()
函数本质
• 作用:将本地协议地址(IP + 端口)与套接字关联
• 适用范围:TCP/UDP协议,IPv4/IPv6地址族
• 返回值:成功返回0,失败返回-1(错误码通过errno
传递)
2. 绑定策略对比
绑定类型 | TCP服务器 | TCP客户端 | UDP |
---|---|---|---|
端口绑定必要性 | 必须绑定知名端口 | 内核自动分配临时端口 | 同TCP |
IP绑定选择 | 可限定接收特定IP请求 | 通常不绑定IP | 同TCP |
🔑 九、通配地址处理机制
1. IPv4与IPv6通配地址对比
特性 | IPv4 (INADDR_ANY ) | IPv6 (in6addr_any ) |
---|---|---|
地址值 | 0 (0.0.0.0) | ::0 |
赋值方式 | servaddr.sin_addr.s_addr = htonl(INADDR_ANY); | memcpy(&servaddr6.sin6_addr, &in6addr_any, sizeof(in6addr_any)); |
字节序处理 | 必须使用htonl() 转换 | 预置网络字节序,无需转换 |
内存布局 | 32位整数 | 128位结构体 |
2. 内核选择IP的触发时机
• TCP连接:在accept()
连接建立时选择出口IP
• UDP通信:在首次sendto()
发送数据报时选择IP
• 动态路由:根据目标地址和路由表自动选择最优IP
⚙️ 十、内核处理机制详解
1. 临时端口分配规则
// 显式触发内核分配临时端口
bind(sockfd, INADDR_ANY, 0); // 端口=0
• 分配时机:立即在bind()
调用时完成
• 获取方式:必须调用getsockname(sockfd, &addr, &addrlen)
2. 未绑定IP时的内核行为
• 服务器端:监听所有网络接口(如绑定INADDR_ANY
的Web服务器)
• 客户端:根据目标地址自动选择外出接口的IP
3. 端口复用策略
• SO_REUSEADDR
选项:允许绑定处于TIME_WAIT
状态的端口
• 典型场景:服务器崩溃后快速重启
💡 十一、关键代码实现
1. IPv4通配地址绑定(修正版)
#include <netinet/in.h>
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 关键:htonl转换
servaddr.sin_port = htons(8080); // 绑定8080端口
bind(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
2. IPv6通配地址绑定(修正版)
#include <netinet/in.h>
struct sockaddr_in6 servaddr6;
memset(&servaddr6, 0, sizeof(servaddr6));
servaddr6.sin6_family = AF_INET6;
memcpy(&servaddr6.sin6_addr, &in6addr_any, sizeof(in6addr_any)); // 结构体复制
servaddr6.sin6_port = htons(8080);
bind(sockfd, (struct sockaddr*)&servaddr6, sizeof(servaddr6));
🛠️ 十二、高级应用场景
1. 多宿主服务器配置
• 需求:单个服务器监听多个IP(如198.69.10.128和198.69.10.129)
• 实现:
# 创建IP别名
ifconfig eth0:0 198.69.10.128 netmask 255.255.255.0
ifconfig eth0:1 198.69.10.129 netmask 255.255.255.0
• 绑定策略:使用INADDR_ANY
让内核自动分发连接
2. RPC服务器动态端口
• 痛点:无法预知端口号
• 解决方案:
- 绑定端口0获取临时端口
- 通过
getsockname()
查询实际端口 - 向端口映射器注册端口信息
⚠️ 十三、常见错误与调试技巧
1. 错误类型
• EADDRINUSE
:端口已被占用(检查netstat -tuln
)
• EACCES
:尝试绑定特权端口(<1024)而无root权限
• 字节序错误:未使用htonl()/htons()
转换导致绑定失败
2. 调试工具链
# 查看当前绑定状态
netstat -tuln | grep :8080
ss -tuln sport = :8080
# 检查内核选择IP
tcpdump -i any port 8080 -nn
📚 十四、延伸学习建议
getsockname()
与getpeername()
的对比使用- IPv6兼容性设计:双协议栈服务器实现
- 安全绑定策略:防止IP欺骗的
IP_BIND_ADDRESS_NO_PORT
选项(Linux 4.2+)
四、listen函数
一、listen
函数的核心作用
listen
函数是TCP服务器编程中的关键步骤,主要完成两项核心任务:
-
转换套接字状态
• 通过socket
创建的套接字默认为主动套接字(用于客户端发起connect
)。
•listen
将其转换为被动套接字,指示内核接收指向该套接字的连接请求。
• 状态转换:调用后,套接字从CLOSED
状态进入LISTEN
状态(见图2-4的TCP状态转换图)。 -
设置连接队列容量
• 参数backlog
定义内核为套接字维护的最大排队连接数。
• 内核维护两个队列(详见下文),backlog
的历史定义模糊,不同系统实现差异显著。
二、内核维护的两个连接队列
1. 未完成连接队列(SYN队列)
• 状态:SYN_RCVD
(等待完成三次握手)。
• 触发条件:收到客户端的SYN
分节后,内核在队列中创建条目,发送SYN+ACK
。
• 超时机制:若未收到客户端的ACK
,条目在75秒(Berkeley实现)后超时删除。
2. 已完成连接队列(ACCEPT队列)
• 状态:ESTABLISHED
(三次握手已完成)。
• 触发条件:收到客户端的ACK
后,条目从未完成队列移至已完成队列。
• accept
行为:服务器进程调用accept
时,从队列头部取出连接;若队列为空,进程阻塞。
3. 队列关系图
三、backlog
参数详解
1. 历史定义与争议
• 原始定义(4.2BSD):未完成队列和已完成队列的总和上限。
• 模糊因子:Berkeley实现将backlog
乘以1.5作为实际队列长度(如backlog=5
允许最多8项)。
• 现代解释:部分系统将backlog
仅视为已完成队列的最大长度(如FreeBSD为backlog+1
)。
2. 实际系统表现(图4-10)
不同操作系统对backlog
的处理差异显著:
• AIX/MacOS:沿用Berkeley算法(backlog×1.5
)。
• FreeBSD:backlog+1
。
• Linux/Solaris:不同版本有不同实现,需实测验证。
3. 设置建议
• 避免backlog=0
:不同系统可能解释为禁止连接或允许最小队列。
• 动态调整:通过环境变量(如LISTENQ
)覆盖默认值,避免重新编译(见图4-9代码)。
• 高并发场景:繁忙服务器(如Web服务器)需设置较大值(如数百),内核自动截断为支持的最大值。
四、异常处理与安全问题
1. 队列满时的行为
• 内核策略:若队列满,忽略新SYN
,不发送RST
,依赖客户端重传。
• 原因:避免因瞬时拥塞导致客户端误判服务不可用。
2. SYN泛滥攻击(SYN Flooding)
• 攻击原理:伪造大量SYN
包填满未完成队列,耗尽服务器资源。
• 防御措施:
• SYN Cookie:无状态处理SYN
,避免消耗队列资源。
• 队列分离:限制未完成队列长度,优先保障已完成队列。
五、现代实践与最佳实践
- 内核调优
• 调整net.core.somaxconn
(Linux)或类似参数,提高系统级最大backlog
。 - 代码示例
// 允许通过环境变量动态设置backlog void Listen(int fd, int backlog) { char *ptr = getenv("LISTENQ"); if (ptr) backlog = atoi(ptr); if (listen(fd, backlog) < 0) err_sys("listen error"); }
- 监控与测试
• 使用工具(如ss
、netstat
)监控队列状态。
• 实测目标系统的backlog
行为(参考图4-10)。
六、总结
• listen
的核心作用:转换套接字角色并定义连接队列容量。
• 队列机制:未完成队列处理三次握手,已完成队列等待accept
。
• backlog
复杂性:历史定义模糊,需结合系统实现调整。
• 安全与实践:动态设置、防御攻击、监控队列状态是关键。
五、accept函数
accept
函数全面解析
accept
是 TCP 服务器编程中的核心函数,用于从已完成连接的队列中提取客户端连接请求,并为每个连接生成新的套接字描述符。以下是对其功能、参数、返回值及实际应用场景的详细分析。
1. 函数定义与基本作用
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
• 功能:从监听套接字 sockfd
的已完成连接队列头部取出下一个已建立的连接(已完成 TCP 三次握手)。
• 阻塞行为:若队列为空,调用进程将被阻塞(默认阻塞模式),直到新连接到达。
• 返回值:
• 成功:返回新的已连接套接字描述符(非负整数)。
• 失败:返回 -1
,并设置 errno
。
2. 参数详解
-
sockfd
(监听套接字)
• 由socket()
创建,并通过bind()
和listen()
初始化的套接字。
• 服务器生命周期内唯一存在,负责持续监听新连接。 -
cliaddr
与addrlen
(客户端地址信息)
•cliaddr
:指向struct sockaddr
的指针,用于接收客户端的协议地址(IP + 端口)。
•addrlen
:值-结果参数(value-result argument):
◦ 调用前:需初始化为cliaddr
结构的实际长度(如sizeof(struct sockaddr_in)
)。
◦ 返回后:内核将其修改为实际存储的地址结构长度。
• 可忽略客户端地址:若不需要客户端信息,可将二者设为NULL
。
3. 关键概念:监听套接字 vs. 已连接套接字
• 监听套接字(Listening Socket)
• 长期存在,仅用于接受新连接请求。
• 服务器通常只创建一个,通过 listen()
设置最大连接队列长度。
• 已连接套接字(Connected Socket)
• 由内核自动创建,代表与单个客户端的 TCP 连接。
• 每个客户端连接对应一个已连接套接字,数据读写通过此描述符进行。
• 服务完成后需显式关闭(close()
),释放资源。
4. 值-结果参数(Value-Result Argument)的运作
• 使用场景:addrlen
用于处理可变长度的地址结构(如不同协议族的地址)。
• 步骤示例:
struct sockaddr_in cliaddr;
socklen_t len = sizeof(cliaddr); // 初始化长度
int connfd = accept(listenfd, (SA*)&cliaddr, &len); // 内核修改len为实际值
• 错误处理:若 cliaddr
缓冲区过小,地址会被截断,需确保缓冲区足够大。
5. 代码示例与运行分析
图4-11 服务器代码关键部分:
// 声明客户端地址结构
struct sockaddr_in cliaddr;
socklen_t len = sizeof(cliaddr); // 初始化长度
// 接受连接并获取客户端信息
connfd = Accept(listenfd, (SA*)&cliaddr, &len);
// 转换网络地址为可读格式
char buff[MAXLINE];
inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)); // IP转字符串
int port = ntohs(cliaddr.sin_port); // 端口号转主机字节序
printf("Connection from %s, port %d\n", buff, port);
运行结果:
solaris % daytimetcpsrv1
connection from 127.0.0.1, port 43388
connection from 192.168.1.20, port 43389
• 客户端行为分析:
• 客户端未调用 bind()
,内核自动选择源 IP 和临时端口。
• 环回地址(127.0.0.1):本地测试时使用。
• 以太网接口地址(192.168.1.20):实际网络通信时使用。
• 临时端口号:由内核动态分配(如 43388、43389)。
6. 权限与端口绑定
• 特权端口(<1024):
示例中服务器绑定到 13 号端口(daytime 服务),需超级用户权限。否则 bind()
失败:
bind error: Permission denied
• 解决方案:
• 以 root
权限运行服务器。
• 改用非特权端口(如 8080)。
7. 协议无关性改进
• 当前代码限制:依赖 IPv4(struct sockaddr_in
)。
• 改进方向:使用 sock_ntop()
替代 inet_ntop()
,支持多种协议族(如 IPv6)。
printf("Connection from %s\n", sock_ntop((SA*)&cliaddr, len));
8. 总结与注意事项
• 核心职责:accept()
仅提取连接,不参与三次握手(由内核完成)。
• 并发处理:单线程服务器需及时关闭已连接套接字,避免资源耗尽。
• 错误处理:需检查返回值,处理 EINTR
(被信号中断)等错误。
• 性能优化:在高并发场景下,可结合非阻塞 I/O 或多路复用技术(如 epoll
)。
六、fork和exec函数
fork
与 exec
函数深度解析
fork
和 exec
是 Unix/Linux 系统中进程管理与程序执行的核心机制,两者通常结合使用以实现进程的创建、并发执行和程序替换。以下是对其功能、用法及底层逻辑的全面分析。
1. fork
函数:创建子进程
函数定义与基本行为
#include <unistd.h>
pid_t fork(void);
• 返回值:
• 父进程:返回子进程的 PID(唯一标识符,用于跟踪子进程)。
• 子进程:返回 0
(明确标识自身为子进程)。
• 错误:返回 -1
(如系统进程数达到上限)。
• 关键特性:
fork
调用一次,返回两次,生成几乎完全相同的父子进程副本(包括代码段、数据段、堆栈、文件描述符表等)。
父子进程的差异
• PID 不同:子进程的 PID 是新分配的,与父进程独立。
• 资源统计归零:子进程的 CPU 时间、信号量等统计信息重置。
• 文件锁不继承:子进程不继承父进程的文件锁。
• 未决信号清空:子进程的未处理信号队列为空。
文件描述符的共享
• 共享机制:父进程在 fork
前打开的文件描述符,子进程会继承并共享同一文件表项(文件偏移量同步变化)。
• 网络服务器典型用法:
父进程通过 accept
获取客户端连接后调用 fork
,子进程处理连接读写,父进程关闭已连接套接字继续监听。
典型应用场景
- 并发服务器(如 HTTP 服务器)
while (1) { int connfd = accept(...); // 接受连接 if (fork() == 0) { // 子进程 close(listenfd); // 关闭监听套接字 handle_request(connfd); // 处理请求 close(connfd); exit(0); // 子进程退出 } close(connfd); // 父进程关闭已连接套接字 }
- 执行新程序(如 Shell 执行命令)
子进程调用exec
替换自身为其他程序(如ls
、gcc
)。
2. exec
函数族:替换进程映像
核心功能
• 替换当前进程:将当前进程的代码段、数据段等替换为新程序文件内容,进程 PID 不变。
• 执行入口:新程序从 main
函数开始执行。
• 返回值:
• 成功:不返回(原进程映像已被覆盖)。
• 失败:返回 -1
(如文件不存在、权限不足)。
6 个 exec
函数的分类与区别
函数名 | 参数传递方式 | 路径解析方式 | 环境变量传递方式 |
---|---|---|---|
execl | 参数列表(可变参数) | 全路径(pathname ) | 继承父进程环境(environ ) |
execv | 参数数组(argv ) | 全路径(pathname ) | 继承父进程环境 |
execle | 参数列表 | 全路径 | 显式指定环境(envp ) |
execve | 参数数组 | 全路径 | 显式指定环境 |
execlp | 参数列表 | 文件名(使用 PATH ) | 继承父进程环境 |
execvp | 参数数组 | 文件名(使用 PATH ) | 继承父进程环境 |
关键区别详解
-
参数传递方式
• 列表形式(l
后缀):以可变参数列表传递,参数以NULL
结尾。execl("/bin/ls", "ls", "-l", NULL);
• 数组形式(
v
后缀):通过argv
数组传递,数组以NULL
结尾。char *argv[] = {"ls", "-l", NULL}; execv("/bin/ls", argv);
-
路径解析方式
•pathname
(全路径):直接指定可执行文件的绝对或相对路径(如/bin/ls
)。
•filename
(文件名):依赖PATH
环境变量搜索可执行文件(如execlp("ls", ...)
)。 -
环境变量传递
• 继承父进程环境:默认使用外部全局变量environ
中的环境变量。
• 显式指定环境:通过envp
数组传递自定义环境变量(以NULL
结尾)。char *envp[] = {"PATH=/usr/bin", "USER=test", NULL}; execle("/bin/ls", "ls", "-l", NULL, envp);
文件描述符的保留与关闭
• 默认行为:调用 exec
后,已打开的文件描述符保持打开(如标准输入输出)。
• 关闭控制:通过 fcntl(fd, F_SETFD, FD_CLOEXEC)
设置 FD_CLOEXEC
标志,使描述符在 exec
时自动关闭。
3. fork
+ exec
的典型工作流(以 Shell 为例)
- 用户输入命令(如
ls -l
)后,Shell 调用fork
创建子进程。 - 子进程调用
execvp
执行ls
程序,替换自身映像:char *argv[] = {"ls", "-l", NULL}; execvp("ls", argv); // 若成功,此处代码不再执行 perror("execvp failed"); exit(1);
- 父进程等待子进程结束(通过
waitpid
),然后继续接受用户输入。
4. 常见误区与注意事项
-
exec
不创建新进程:
仅替换当前进程的代码和数据,PID、打开文件、信号处理等状态保留。 -
资源泄漏风险:
子进程中未关闭的文件描述符可能被新程序继承,需显式关闭或设置FD_CLOEXEC
。 -
信号处理重置:
exec
后,信号处理函数恢复为默认行为(因原程序的信号处理代码已被替换)。 -
僵尸进程处理:
父进程需通过wait
或waitpid
回收子进程资源,避免僵尸进程累积。
5. 总结
• fork
:实现进程复制,是 Unix 并发编程的基础。
• exec
:实现程序动态加载,赋予进程执行任意程序的能力。
• 组合使用:fork
+ exec
是 Shell、服务器、守护进程等实现多任务的核心模式。
(例如:Apache 服务器为每个连接 fork 子进程,子进程处理请求后退出;Shell 为每条命令 fork 子进程并 exec 目标程序。)
通过掌握 fork
和 exec
的机制与差异,开发者能够灵活设计高效、安全的并发程序,同时避免资源泄漏与进程管理错误。
七、并发服务器
并发服务器详解
迭代服务器 vs. 并发服务器
• 迭代服务器(Iterative Server):
顺序处理客户端请求,一次仅服务一个客户端,处理完毕后才能接受下一个连接。适用于快速响应的服务(如时间获取服务器)。
• 并发服务器(Concurrent Server):
同时处理多个客户端请求,通过多进程/多线程实现并行服务。适用于请求处理时间较长的场景(如文件传输、数据库查询)。
并发服务器核心实现:fork
模型
下面展示了典型的并发服务器逻辑:
pid_t pid;
int listenfd, connfd;
// 创建监听套接字
listenfd = socket(AF_INET, SOCK_STREAM, 0);
// 绑定地址和端口
bind(listenfd, ...);
// 监听连接
listen(listenfd, LISTENQ);
for (;;) {
// 接受连接(阻塞)
connfd = Accept(listenfd, ...);
// 创建子进程
if ((pid = fork()) == 0) { // 子进程
Close(listenfd); // 关闭监听套接字(子进程不需要)
doit(connfd); // 处理客户端请求
Close(connfd); // 关闭已连接套接字(显式关闭)
exit(0); // 子进程退出
}
Close(connfd); // 父进程关闭已连接套接字
}
关键机制解析
1. 套接字描述符的继承与关闭
• fork
后的文件描述符共享:
父进程调用 fork
后,子进程复制父进程的文件描述符表,listenfd
和 connfd
的引用计数均增加 1。
• 引用计数(Reference Count):
每个文件描述符关联的套接字内核对象维护一个引用计数。仅当引用计数归零时,套接字资源才会被释放,TCP 连接终止(发送 FIN
)。
示例:引用计数变化
阶段 | listenfd 引用计数 | connfd 引用计数 |
---|---|---|
accept 返回后 | 1 | 1 |
fork 后 | 2 | 2 |
父进程关闭 connfd | 2 | 1(父进程关闭) |
子进程关闭 connfd | 2 | 0(连接终止) |
2. 父子进程职责分离
• 父进程:
• 保持 listenfd
开启,持续监听新连接。
• 关闭 connfd
,避免资源占用(实际连接由子进程处理)。
• 子进程:
• 关闭 listenfd
,避免干扰父进程的监听。
• 处理 connfd
的读写操作,完成后显式关闭 connfd
。
3. 连接终止的触发条件
• 父进程关闭 connfd
:
仅减少引用计数,不触发 TCP FIN
(引用计数仍为 1,子进程持有 connfd
)。
• 子进程关闭 connfd
:
引用计数归零,触发 TCP 连接终止流程。
套接字状态演变图示
图4-14:accept
返回前的状态
• 服务器阻塞在 accept
,等待客户端连接。
• 客户端发起 connect
,TCP 三次握手完成前,服务器处于等待状态。
图4-15:accept
返回后的状态
• accept
返回 connfd
,表示新连接建立。
• listenfd
仍用于监听,connfd
用于与当前客户端通信。
图4-16:fork
后的状态
• 父进程和子进程共享 listenfd
和 connfd
。
• 引用计数均为 2。
图4-17:父子进程关闭套接字后的状态
• 父进程:关闭 connfd
,仅保留 listenfd
继续监听。
• 子进程:关闭 listenfd
,保留 connfd
处理请求。
• 最终状态:父进程监听新连接,子进程服务当前连接。
性能与资源管理注意事项
- 子进程资源释放:
子进程需显式关闭不需要的套接字,并通过exit
终止,避免僵尸进程(需父进程调用waitpid
回收)。 - 文件描述符泄漏风险:
未关闭的listenfd
或connfd
可能导致资源耗尽(如达到系统最大文件描述符限制)。 - 高并发下的扩展性:
fork
模型适用于低并发场景,高并发时需考虑进程/线程池或异步 I/O(如epoll
)。
代码优化与实践建议
- 显式关闭描述符:
尽管exit
会关闭所有描述符,显式调用close
可提高代码可读性,避免隐含依赖。 - 错误处理:
需检查fork
、accept
等系统调用的返回值,处理EINTR
(被信号中断)等错误。 - 协议无关性:
使用getaddrinfo
和通用套接字地址结构(struct sockaddr_storage
)提升代码可移植性。
总结
通过 fork
实现的并发服务器模型是 Unix 系统的经典设计,其核心思想包括:
• 进程分离:父进程专注监听,子进程处理请求。
• 引用计数管理:通过合理关闭描述符控制连接生命周期。
• 资源隔离:子进程独立运行,避免主进程阻塞。
八、close函数
close
函数深度解析
close
函数是 Unix 系统中用于关闭文件描述符(包括套接字)的核心操作,其在 TCP 套接字编程中具有特殊行为。以下是对图中内容的全面解读,涵盖功能、底层机制、引用计数及实际应用场景。
1. 函数定义与基本行为
#include <unistd.h>
int close(int sockfd);
• 功能:关闭套接字描述符,终止 TCP 连接(默认行为)。
• 返回值:
• 成功:返回 0
。
• 失败:返回 -1
(如描述符无效或中断)。
• 关键特性:
关闭后,描述符 sockfd
不可再用于读写操作(read
/write
会失败)。
2. close
的默认行为
当调用 close
关闭 TCP 套接字时,内核执行以下操作:
- 标记套接字为已关闭:立即释放用户态对
sockfd
的使用权。 - 发送剩余数据:尝试发送内核缓冲区中已排队的数据。
- 触发 TCP 连接终止:
• 发送FIN
包,启动四次挥手流程(正常终止序列)。
• 等待对端确认后,彻底释放连接资源。
示例流程:
应用调用 close(sockfd)
→ 内核发送 FIN
→ 对端回复 ACK
→ 对端发送 FIN
→ 本地回复 ACK → 连接终止
3. 描述符引用计数(Reference Count)
• 核心概念:
每个套接字在内核中维护一个引用计数,表示当前有多少个进程/线程持有其描述符。
• 操作影响:
• fork
调用:子进程复制父进程的描述符表,引用计数 +1
。
• close
调用:引用计数 -1
,仅当计数归零时,触发真正的资源释放和连接终止。
并发服务器中的引用计数示例
// 父进程代码片段
connfd = accept(...); // 引用计数=1
if (fork() == 0) { // 子进程
close(listenfd); // 引用计数-1(listenfd 父2→1)
doit(connfd); // 处理请求
close(connfd); // 引用计数-1(connfd 2→1)
exit(0);
}
close(connfd); // 父进程引用计数-1(connfd 2→1)
• 关键结论:
父进程调用 close(connfd)
后,引用计数仍为 1(子进程仍持有),TCP 连接不会终止。
4. close
的局限性及替代方案
何时 close
无法立即终止连接?
• 多进程/线程共享描述符:
引用计数 > 1 时,close
仅减少计数,不触发 FIN
(如并发服务器中子进程未关闭 connfd
)。
• 资源泄漏风险:
未正确关闭的描述符会导致:
• 文件描述符耗尽(进程可打开的最大描述符数受限)。
• TCP 连接长期滞留(无法完成四次挥手)。
强制终止连接:shutdown
函数
#include <sys/socket.h>
int shutdown(int sockfd, int how);
• 功能:直接控制连接的读写方向,无视引用计数。
• 参数 how
:
• SHUT_RD
:关闭读方向。
• SHUT_WR
:关闭写方向(发送 FIN
)。
• SHUT_RDWR
:同时关闭读写。
• 典型场景:
在并发服务器中,父进程希望立即通知对端连接终止,而无需等待子进程退出。
5. SO_LINGER
套接字选项
• 作用:修改 close
的默认行为,控制是否等待未发送数据及等待时间。
• 结构体定义:
struct linger {
int l_onoff; // 0=关闭选项,非0=启用
int l_linger; // 等待时间(秒)
};
• 行为模式:
• l_onoff = 0
(默认):
close
立即返回,后台尝试发送剩余数据。
• l_onoff != 0
,l_linger = 0
:
立即终止连接,丢弃未发送数据(发送 RST
而非 FIN
)。
• l_onoff != 0
,l_linger > 0
:
阻塞 close
直到数据发送完毕或超时。
代码示例
struct linger opt = {1, 5}; // 等待5秒
setsockopt(sockfd, SOL_SOCKET, SO_LINGER, &opt, sizeof(opt));
close(sockfd); // 阻塞最多5秒
6. 并发服务器中的 close
实践
正确管理描述符
- 父进程职责:
• 关闭已连接套接字(connfd
),避免描述符泄漏。
• 保持监听套接字(listenfd
)开启,持续接受新连接。 - 子进程职责:
• 关闭监听套接字(listenfd
),避免干扰父进程。
• 处理完请求后显式关闭connfd
,确保引用计数归零。
常见错误与后果
• 父进程未关闭 connfd
:
子进程退出后,connfd
引用计数仍为 1,连接无法终止。
• 子进程未关闭 listenfd
:
所有子进程持有 listenfd
,导致父进程无法正确重启或终止。
7. 总结与最佳实践
• 优先使用 close
:在无特殊需求时,依赖其默认行为。
• 引用计数意识:在多进程/线程模型中,确保每个持有者正确关闭描述符。
• 替代方案选择:
• 立即终止连接 → shutdown(sockfd, SHUT_WR)
。
• 强制丢弃数据 → SO_LINGER
+ l_linger = 0
。
• 确保数据送达 → SO_LINGER
+ 合理超时。
• 资源监控:
使用 lsof
或 /proc/<pid>/fd
检查未关闭的描述符。
九、getsockname和getpeername函数
getsockname
和 getpeername
函数详解
1. 函数定义与功能
#include <sys/socket.h>
// 获取与套接字关联的本地协议地址(本地IP和端口)
int getsockname(int sockfd, struct sockaddr *localaddr, socklen_t *addrlen);
// 获取与套接字关联的对端协议地址(远端IP和端口)
int getpeername(int sockfd, struct sockaddr *peeraddr, socklen_t *addrlen);
• 返回值:
• 成功:返回 0
。
• 失败:返回 -1
,并设置 errno
。
• 参数:
• sockfd
:已打开的套接字描述符。
• localaddr
/peeraddr
:指向 sockaddr
结构的指针,用于存储地址信息。
• addrlen
:值-结果参数,调用前需初始化为缓冲区的长度,返回后为实际地址长度。
2. 核心特性
(1) 值-结果参数(Value-Result Argument)
• 输入:调用者需初始化 addrlen
为 sockaddr
缓冲区的长度。
• 输出:函数返回时,addrlen
被修改为实际写入的地址长度。
• 目的:防止缓冲区溢出,并支持可变长度的地址结构(如IPv4/IPv6)。
(2) 命名误解
• “name”的误导性:函数名中的“name”易误解为域名解析,实际返回的是 协议地址(IP地址+端口号)。
• 与域名的无关性:这两个函数不涉及DNS查询,仅操作套接字的内核状态。
3. 核心应用场景
(1) 获取未绑定客户端的本地地址
• 场景:TCP客户端未调用 bind
,直接调用 connect
,由内核自动分配本地IP和端口。
• 代码示例:
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
connect(sockfd, ...); // 连接服务器
struct sockaddr_in local_addr;
socklen_t len = sizeof(local_addr);
getsockname(sockfd, (struct sockaddr*)&local_addr, &len); // 获取本地地址
(2) 获取通配绑定的服务器端口
• 场景:服务器调用 bind
时使用通配IP(INADDR_ANY
)或通配端口(0
),由内核分配具体地址。
• 代码示例:
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in servaddr;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 通配IP
servaddr.sin_port = htons(0); // 通配端口
bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
getsockname(listenfd, (struct sockaddr*)&servaddr, &len); // 获取实际端口
(3) 获取套接字的地址族
• 场景:动态确定套接字的协议族(IPv4/IPv6/其他)。
• 代码示例(图4-19):
int sockfd_to_family(int sockfd) {
struct sockaddr_storage ss;
socklen_t len = sizeof(ss);
if (getsockname(sockfd, (SA*)&ss, &len) < 0) return -1;
return ss.ss_family; // 返回地址族(如AF_INET、AF_INET6)
}
• sockaddr_storage
:通用地址结构,兼容所有协议族,避免内存不足。
(4) 服务器通过 exec
后获取客户信息
• 场景:父进程(如 inetd
)调用 accept
获取客户端地址后,通过 fork
+ exec
启动子进程服务程序。由于 exec
会替换进程内存映像,原客户地址信息丢失,需通过 getpeername
重新获取。
• 流程:
inetd
调用accept
接受连接,获得connfd
和客户地址。fork
创建子进程,子进程共享connfd
和客户地址。- 子进程调用
exec
启动服务程序(如telnetd
),客户地址信息被清除。 - 服务程序通过
getpeername(connfd, ...)
重新获取客户地址。
关键点:
• 描述符跨 exec
保持打开:exec
默认关闭所有文件描述符,除非设置 FD_CLOEXEC
标志。
• inetd的约定:将已连接套接字描述符(connfd
)复制到标准输入(0)、输出(1)、错误(2),服务程序可直接使用这些描述符。
4. inetd
服务器示例解析
流程示意图(图4-18)
实现细节
• 描述符传递方法:
• 方法1:将 connfd
作为命令行参数传递(如 execv("server", {"server", "3", NULL})
)。
• 方法2:约定将 connfd
绑定到固定描述符(如0、1、2),inetd
使用此方法。
• 代码片段:
// inetd子进程中
dup2(connfd, 0); // 将connfd复制到标准输入
dup2(connfd, 1); // 复制到标准输出(可选)
close(connfd); // 关闭原描述符
execvp("telnetd", ...); // 启动服务程序
5. 技术细节与注意事项
(1) 地址结构的通用性
• sockaddr_storage
:
用于存储任意协议族的地址结构,大小足够容纳IPv4(sockaddr_in
)和IPv6(sockaddr_in6
)。
• 代码示例:
struct sockaddr_storage ss;
socklen_t len = sizeof(ss);
getsockname(sockfd, (SA*)&ss, &len);
(2) 未绑定套接字的处理
• POSIX规范支持:允许对未绑定的套接字调用 getsockname
,返回的地址族有效,但IP和端口可能为默认值(如 0.0.0.0:0
)。
(3) 错误处理
• 常见错误:
• EBADF
:无效的套接字描述符。
• ENOTSOCK
:描述符不是套接字。
• ENOBUFS
:内核内存不足。
6. 总结
• getsockname
:
用于获取套接字的本地协议地址,适用于动态分配的IP/端口、地址族查询等场景。
• getpeername
:
用于获取对端协议地址,关键用于通过 exec
启动的服务程序追溯客户身份。
• 核心价值:
在多进程、动态地址分配的网络编程中,确保程序能正确获取和传递连接信息,避免硬编码或信息丢失。
十、总结
所有客户和服务器都从调用socket开始,它返回一个套接字描述符。客户随后调用connect,服务器则调用bind、listen和accept。套接字通常使用标准的close函数关闭,不过我们将看到使用shutdown函数关闭套接字的另一种方法,我们还要查看SO_LINGER套接字选项对于关闭套接字的影响。
大多数TCP服务器是并发的,它们为每个待处理的客户连接调用fork派生一个子进程。我们将看到,大多数UDP服务器却是迭代的。尽管这两个模型已经成功地运用了许多年,我们仍将在后面探讨使用线程和进程的其他服务器程序设计方法。