当前位置: 首页 > article >正文

UNIX网络编程笔记:基本TCP套接字编程

一、socket函数

一、socket函数核心参数与协议组合
  1. 函数原型与基本功能

    #include <sys/socket.h>
    int socket(int family, int type, int protocol);
    

    功能:创建通信端点(套接字),返回描述符供后续操作。
    返回值:成功返回非负描述符,失败返回 -1,错误码存于 errno

  2. 核心参数详解

    参数可选值及说明
    familyAF_INET(IPv4)、AF_INET6(IPv6)、AF_LOCAL(本地通信)、AF_ROUTE(路由操作)、AF_KEY(IPsec密钥管理)
    typeSOCK_STREAM(TCP流式)、SOCK_DGRAM(UDP数据报)、SOCK_RAW(原始套接字)、SOCK_SEQPACKET(有序分组如SCTP)
    protocol通常设为 0(自动选择默认协议),或显式指定如 IPPROTO_TCPIPPROTO_UDP
  3. 有效参数组合与默认协议

    familytype默认协议典型应用
    AF_INETSOCK_STREAMTCP (IPPROTO_TCP)HTTP、SSH等可靠传输场景
    AF_INETSOCK_DGRAMUDP (IPPROTO_UDP)DNS查询、实时音视频传输
    AF_INET6SOCK_STREAMTCP支持IPv6的服务器开发
    AF_LOCALSOCK_STREAMUnix域字节流本地进程间高性能通信(无需网络协议栈)
    AF_ROUTESOCK_RAW路由表操作网络监控工具(如 netstat
    AF_KEYSOCK_RAWIPsec密钥管理VPN或加密通信配置
  4. 特殊类型与协议
    原始套接字(SOCK_RAW
    ◦ 用途:构造自定义IP数据包(如实现Ping工具、嗅探器)。
    ◦ 权限要求:需root权限(Linux)或管理员权限(Windows)。
    SCTP与SOCK_SEQPACKET
    ◦ 支持多宿主、消息边界保留,适用于实时性要求高的场景(如VoIP)。
    ◦ 需安装 lksctp-tools 库(Linux)。


二、TCP客户端/服务器编程流程
  1. 服务器端流程

    socket() → bind() → listen() → accept() → read()/write() → close()
    

    关键步骤
    bind():绑定IP和端口(INADDR_ANY 表示监听所有接口)。
    listen():设置监听队列长度(backlog参数)。
    accept():阻塞直到客户端连接,返回 已连接套接字(与监听套接字分离)。

  2. 客户端流程

    socket() → connect() → write()/read() → close()
    

    关键步骤
    connect():触发TCP三次握手,建立连接。
    ◦ 数据交换:通过read()/write()send()/recv()进行双向通信。

  3. TCP与UDP对比

    特性TCPUDP
    连接方式面向连接(可靠传输)无连接(尽力而为)
    数据边界字节流(无固定边界)数据报(保留边界)
    适用场景文件传输、Web请求实时游戏、广播

三、关键注意事项与最佳实践
  1. 协议兼容性
    • 避免无效组合(如SOCK_DGRAM+IPPROTO_TCP),错误码为 EPROTONOSUPPORT
    AF_LOCALAF_UNIX 等价,但建议使用POSIX标准的 AF_LOCAL

  2. 系统依赖性与权限
    AF_ROUTEAF_KEY 仅在类Unix系统(如Linux、BSD)中可用。
    • 原始套接字需特权权限,且可能被防火墙拦截。

  3. 开发规范
    头文件:使用 <sys/socket.h><netinet/in.h>,而非C++头文件。
    错误处理:检查所有系统调用返回值,处理 EINTR(信号中断)等错误。

  4. 性能优化
    • 设置 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函数基本定义与作用
  1. 函数原型与参数

    #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

  2. 核心功能
    触发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返回错误码 EHOSTUNREACHENETUNREACH
处理逻辑

// 示例:目标网络不可达
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函数的底层行为与系统依赖
  1. TCP三次握手过程
    步骤

    1. 客户端发送SYN段(序列号随机生成,如x)。
    2. 服务器回复SYN-ACK段(序列号y,确认号x+1)。
    3. 客户端发送ACK段(确认号y+1),连接建立。
      内核自动完成:无需应用层干预。
  2. 系统依赖的细节
    重试次数与超时:不同系统(如BSD、Linux)的重试策略不同。
    ◦ BSD系统:初始等待3秒,后续重试间隔指数级增长。
    ◦ Linux:默认总超时约为120秒(可通过sysctl调整)。
    ICMP错误处理
    ◦ 某些系统(如早期BSD)可能忽略ICMP错误,导致超时而非立即失败。


四、关键注意事项与最佳实践
  1. 错误处理优先级
    • 优先检查 ECONNREFUSED(明确的服务不可用)。
    • 次优处理 ETIMEDOUT(网络或服务器问题)。
    • 最后处理 EHOSTUNREACH(网络配置问题)。

  2. 非阻塞模式下的处理
    • 使用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) {
                    // 处理具体错误
                }
            }
        }
    }
    
  3. 避免常见陷阱
    地址复用:设置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_instruct sockaddr_in6
addrlen:地址结构的实际长度(例如sizeof(struct sockaddr_in)
返回值
• 成功返回0,失败返回-1并设置errno

2. 绑定目标类型
绑定内容设置字段典型场景
指定IP + 指定端口sin_addrsin_port均明确赋值Web服务器绑定0.0.0.0:80
仅指定IPsin_addr赋值,sin_port=0多网卡服务器限定接收接口
仅指定端口sin_addr=INADDR_ANYsin_port赋值FTP服务器绑定:21
不指定IP和端口INADDR_ANYsin_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. **动态端口注册流程
  1. 内核分配临时端口
    bind(sockfd, INADDR_ANY, 0);  // 端口=0触发内核分配
    getsockname(sockfd, &addr, &addrlen); // 获取实际端口号
    
  2. 向RPC端口映射器注册
    • 通过rpcbind服务(端口111)注册(program, version)与端口号的映射关系
  3. 客户端查询流程
    • 客户端先查询端口映射器获取实际端口
    • 再向目标端口发起连接

五、关键代码示例

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                 *:*

七、设计建议

  1. 服务器绑定策略
    • 生产环境优先绑定INADDR_ANY,通过防火墙规则限制访问IP
    • 开发环境可绑定127.0.0.1防止外部访问
  2. 客户端绑定慎用
    • 除非需要固定源IP或端口,否则依赖内核自动选择
  3. 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服务器动态端口

痛点:无法预知端口号
解决方案

  1. 绑定端口0获取临时端口
  2. 通过getsockname()查询实际端口
  3. 向端口映射器注册端口信息

⚠️ 十三、常见错误与调试技巧

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

📚 十四、延伸学习建议

  1. getsockname()getpeername()的对比使用
  2. IPv6兼容性设计:双协议栈服务器实现
  3. 安全绑定策略:防止IP欺骗的IP_BIND_ADDRESS_NO_PORT选项(Linux 4.2+)

四、listen函数

一、listen函数的核心作用

listen函数是TCP服务器编程中的关键步骤,主要完成两项核心任务:

  1. 转换套接字状态
    • 通过socket创建的套接字默认为主动套接字(用于客户端发起connect)。
    listen将其转换为被动套接字,指示内核接收指向该套接字的连接请求。
    状态转换:调用后,套接字从CLOSED状态进入LISTEN状态(见图2-4的TCP状态转换图)。

  2. 设置连接队列容量
    • 参数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)。
FreeBSDbacklog+1
Linux/Solaris:不同版本有不同实现,需实测验证。

3. 设置建议

避免backlog=0:不同系统可能解释为禁止连接或允许最小队列。
动态调整:通过环境变量(如LISTENQ)覆盖默认值,避免重新编译(见图4-9代码)。
高并发场景:繁忙服务器(如Web服务器)需设置较大值(如数百),内核自动截断为支持的最大值。


四、异常处理与安全问题

1. 队列满时的行为

内核策略:若队列满,忽略新SYN,不发送RST,依赖客户端重传。
原因:避免因瞬时拥塞导致客户端误判服务不可用。

2. SYN泛滥攻击(SYN Flooding)

攻击原理:伪造大量SYN包填满未完成队列,耗尽服务器资源。
防御措施
SYN Cookie:无状态处理SYN,避免消耗队列资源。
队列分离:限制未完成队列长度,优先保障已完成队列。


五、现代实践与最佳实践

  1. 内核调优
    • 调整net.core.somaxconn(Linux)或类似参数,提高系统级最大backlog
  2. 代码示例
    // 允许通过环境变量动态设置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");
    }
    
  3. 监控与测试
    • 使用工具(如ssnetstat)监控队列状态。
    • 实测目标系统的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. 参数详解
  1. sockfd(监听套接字)
    • 由 socket() 创建,并通过 bind()listen() 初始化的套接字。
    • 服务器生命周期内唯一存在,负责持续监听新连接。

  2. cliaddraddrlen(客户端地址信息)
    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函数

forkexec 函数深度解析

forkexec 是 Unix/Linux 系统中进程管理与程序执行的核心机制,两者通常结合使用以实现进程的创建、并发执行和程序替换。以下是对其功能、用法及底层逻辑的全面分析。


1. fork 函数:创建子进程
函数定义与基本行为
#include <unistd.h>
pid_t fork(void);

返回值
父进程:返回子进程的 PID(唯一标识符,用于跟踪子进程)。
子进程:返回 0(明确标识自身为子进程)。
错误:返回 -1(如系统进程数达到上限)。

关键特性
fork 调用一次,返回两次,生成几乎完全相同的父子进程副本(包括代码段、数据段、堆栈、文件描述符表等)。

父子进程的差异

PID 不同:子进程的 PID 是新分配的,与父进程独立。
资源统计归零:子进程的 CPU 时间、信号量等统计信息重置。
文件锁不继承:子进程不继承父进程的文件锁。
未决信号清空:子进程的未处理信号队列为空。

文件描述符的共享

共享机制:父进程在 fork 前打开的文件描述符,子进程会继承并共享同一文件表项(文件偏移量同步变化)。
网络服务器典型用法
父进程通过 accept 获取客户端连接后调用 fork,子进程处理连接读写,父进程关闭已连接套接字继续监听。

典型应用场景
  1. 并发服务器(如 HTTP 服务器)
    while (1) {
        int connfd = accept(...);  // 接受连接
        if (fork() == 0) {         // 子进程
            close(listenfd);       // 关闭监听套接字
            handle_request(connfd); // 处理请求
            close(connfd);
            exit(0);               // 子进程退出
        }
        close(connfd);             // 父进程关闭已连接套接字
    }
    
  2. 执行新程序(如 Shell 执行命令)
    子进程调用 exec 替换自身为其他程序(如 lsgcc)。

2. exec 函数族:替换进程映像
核心功能

替换当前进程:将当前进程的代码段、数据段等替换为新程序文件内容,进程 PID 不变
执行入口:新程序从 main 函数开始执行。
返回值
成功:不返回(原进程映像已被覆盖)。
失败:返回 -1(如文件不存在、权限不足)。

6 个 exec 函数的分类与区别
函数名参数传递方式路径解析方式环境变量传递方式
execl参数列表(可变参数)全路径(pathname继承父进程环境(environ
execv参数数组(argv全路径(pathname继承父进程环境
execle参数列表全路径显式指定环境(envp
execve参数数组全路径显式指定环境
execlp参数列表文件名(使用 PATH继承父进程环境
execvp参数数组文件名(使用 PATH继承父进程环境
关键区别详解
  1. 参数传递方式
    列表形式(l 后缀):以可变参数列表传递,参数以 NULL 结尾。

    execl("/bin/ls", "ls", "-l", NULL);
    

    数组形式(v 后缀):通过 argv 数组传递,数组以 NULL 结尾。

    char *argv[] = {"ls", "-l", NULL};
    execv("/bin/ls", argv);
    
  2. 路径解析方式
    pathname(全路径):直接指定可执行文件的绝对或相对路径(如 /bin/ls)。
    filename(文件名):依赖 PATH 环境变量搜索可执行文件(如 execlp("ls", ...))。

  3. 环境变量传递
    继承父进程环境:默认使用外部全局变量 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 为例)
  1. 用户输入命令(如 ls -l)后,Shell 调用 fork 创建子进程。
  2. 子进程调用 execvp 执行 ls 程序,替换自身映像:
    char *argv[] = {"ls", "-l", NULL};
    execvp("ls", argv);  // 若成功,此处代码不再执行
    perror("execvp failed");
    exit(1);
    
  3. 父进程等待子进程结束(通过 waitpid),然后继续接受用户输入。

4. 常见误区与注意事项
  1. exec 不创建新进程
    仅替换当前进程的代码和数据,PID、打开文件、信号处理等状态保留。

  2. 资源泄漏风险
    子进程中未关闭的文件描述符可能被新程序继承,需显式关闭或设置 FD_CLOEXEC

  3. 信号处理重置
    exec 后,信号处理函数恢复为默认行为(因原程序的信号处理代码已被替换)。

  4. 僵尸进程处理
    父进程需通过 waitwaitpid 回收子进程资源,避免僵尸进程累积。


5. 总结

fork:实现进程复制,是 Unix 并发编程的基础。
exec:实现程序动态加载,赋予进程执行任意程序的能力。
组合使用fork + exec 是 Shell、服务器、守护进程等实现多任务的核心模式。
(例如:Apache 服务器为每个连接 fork 子进程,子进程处理请求后退出;Shell 为每条命令 fork 子进程并 exec 目标程序。)

通过掌握 forkexec 的机制与差异,开发者能够灵活设计高效、安全的并发程序,同时避免资源泄漏与进程管理错误。

七、并发服务器

并发服务器详解

迭代服务器 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 后,子进程复制父进程的文件描述符表,listenfdconnfd 的引用计数均增加 1。
引用计数(Reference Count)
每个文件描述符关联的套接字内核对象维护一个引用计数。仅当引用计数归零时,套接字资源才会被释放,TCP 连接终止(发送 FIN)。

示例:引用计数变化
阶段listenfd 引用计数connfd 引用计数
accept 返回后11
fork22
父进程关闭 connfd21(父进程关闭)
子进程关闭 connfd20(连接终止)
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 后的状态

• 父进程和子进程共享 listenfdconnfd
• 引用计数均为 2。
在这里插入图片描述

图4-17:父子进程关闭套接字后的状态

父进程:关闭 connfd,仅保留 listenfd 继续监听。
子进程:关闭 listenfd,保留 connfd 处理请求。
• 最终状态:父进程监听新连接,子进程服务当前连接。
在这里插入图片描述

性能与资源管理注意事项
  1. 子进程资源释放
    子进程需显式关闭不需要的套接字,并通过 exit 终止,避免僵尸进程(需父进程调用 waitpid 回收)。
  2. 文件描述符泄漏风险
    未关闭的 listenfdconnfd 可能导致资源耗尽(如达到系统最大文件描述符限制)。
  3. 高并发下的扩展性
    fork 模型适用于低并发场景,高并发时需考虑进程/线程池或异步 I/O(如 epoll)。
代码优化与实践建议
  1. 显式关闭描述符
    尽管 exit 会关闭所有描述符,显式调用 close 可提高代码可读性,避免隐含依赖。
  2. 错误处理
    需检查 forkaccept 等系统调用的返回值,处理 EINTR(被信号中断)等错误。
  3. 协议无关性
    使用 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 套接字时,内核执行以下操作:

  1. 标记套接字为已关闭:立即释放用户态对 sockfd 的使用权。
  2. 发送剩余数据:尝试发送内核缓冲区中已排队的数据。
  3. 触发 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 != 0l_linger = 0
立即终止连接,丢弃未发送数据(发送 RST 而非 FIN)。
l_onoff != 0l_linger > 0
阻塞 close 直到数据发送完毕或超时。

代码示例
struct linger opt = {1, 5}; // 等待5秒
setsockopt(sockfd, SOL_SOCKET, SO_LINGER, &opt, sizeof(opt));
close(sockfd); // 阻塞最多5秒

6. 并发服务器中的 close 实践
正确管理描述符
  1. 父进程职责
    • 关闭已连接套接字(connfd),避免描述符泄漏。
    • 保持监听套接字(listenfd)开启,持续接受新连接。
  2. 子进程职责
    • 关闭监听套接字(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函数

getsocknamegetpeername 函数详解

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)

输入:调用者需初始化 addrlensockaddr 缓冲区的长度。
输出:函数返回时,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 重新获取。
流程

  1. inetd 调用 accept 接受连接,获得 connfd 和客户地址。
  2. fork 创建子进程,子进程共享 connfd 和客户地址。
  3. 子进程调用 exec 启动服务程序(如 telnetd),客户地址信息被清除。
  4. 服务程序通过 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服务器却是迭代的。尽管这两个模型已经成功地运用了许多年,我们仍将在后面探讨使用线程和进程的其他服务器程序设计方法。


http://www.kler.cn/a/596904.html

相关文章:

  • 《Gradio Python 客户端入门》
  • Unity 与 JavaScript 的通信交互:实现跨平台的双向通信
  • 【FPGA开发】FPGA点亮LED灯(增加按键暂停恢复/复位操作)
  • 常用高压30V以上DCDC开关电源稳压器
  • Linux——线程
  • WMS仓储管理系统架构介绍
  • JavaWeek3-泛型,树和集合List接口
  • 西门子仿真实例位置
  • Maven环境搭建与配置
  • 建筑安全员考试:“知识拓展” 关键词驱动的深度备考攻略
  • 【单片机通信技术应用——学习笔记三】液晶屏显示技术,取模软件的应用
  • 向量库特点和使用场景
  • 强化学习(赵世钰版)-学习笔记(完)(10.Actor-Critic方法)
  • 【vue的some和filter】
  • C语言入门教程100讲(5)基本数据类型
  • Retrofit中Jsoup解析html(一)
  • 组合总和||
  • Postgresql 删除数据库报错
  • 【10】高效存储MongoDB的用法
  • LeetCode 热题 100_划分字母区间(80_763_中等_C++)(贪心算法(求并集))