Linux网络编程从入门到精通

Linux网络编程从入门到放弃

简介:网络编程是现代软件开发中不可或缺的核心技能。无论是构建高并发 Web 服务器、实时聊天系统,还是分布式存储系统,都需要扎实的网络编程基础。本文将从 TCP/UDP 协议基础出发,系统讲解 Socket API 的使用方法,给出完整的 TCP/UDP 服务端与客户端示例代码,深入剖析 select/poll/epoll 三种 I/O 复用机制的原理与实现,全面介绍 Linux 下的 5 种 I/O 模型,并涵盖套接字选项设置、原始套接字、libevent 事件驱动编程等高级主题。读完本文,你将具备独立开发高性能网络应用的能力。


一、TCP/UDP 协议基础

1.1 网络体系结构

网络体系结构采用分层的思想,每一层向上层提供服务,同时使用下层提供的服务。有两种主要的网络体系结构模型:

OSI 七层模型 vs TCP/IP 四层模型

复制代码
OSI模型              TCP/IP模型          协议示例
-----------          -----------         --------
应用层                                   HTTP/FTP/Telnet
表示层               应用层              DNS/SMTP
会话层
传输层               传输层              TCP/UDP
网络层               网络层              IP/ICMP/IGMP/ARP
数据链路层           网络接口层          以太网/PPP
物理层                                   IEEE 802.x

各层协议头大小(必须记住):

  • 以太网协议头:14 字节
  • IP 头:20 字节
  • TCP 头:20 字节
  • UDP 头:8 字节
  • MTU(最大传输单元):1500 字节

1.2 TCP 协议

TCP(传输控制协议)是一种面向连接的、可靠的、点对点的传输层协议。

TCP 的核心特性

  • 提供确认、流量控制和拥塞控制
  • 自动检测数据报错误并提供重发功能
  • 按序排列数据,剔除重复数据
  • 超时重发,自动调整超时值
  • 全双工通信

TCP 发送的消息没有边界,所以必须自己控制长度进行接收。TCP 发送 0 个字节时,接收端继续阻塞。

TCP 三次握手建立连接

复制代码
主机A                           主机B
  |------ SYN(序列号1234) ------>|    (1) A发起连接请求
  |<----- SYN(序列号9876) ------|    (2) B应答并发起连接
  |<----- ACK(1234+1) ---------|    (2) B确认A的序列号
  |------ ACK(9876+1) --------->|    (3) A确认B的序列号
  |                              |
  |=== 连接建立,开始传输数据 ===|

TCP 四次挥手释放连接

复制代码
主机A                           主机B
  |------ FIN(1234) ----------->|    (1) A请求关闭
  |<----- ACK(1234+1) ---------|    (2) B确认
  |<----- FIN(9876) -----------|    (3) B请求关闭
  |------ ACK(9876+1) -------->|    (4) A确认

1.3 UDP 协议

UDP(用户数据报协议)是一种无连接的、不可靠的传输层协议。

UDP 的特性

  • 不需要建立连接,可以直接发送数据
  • 支持一对一、一对多、多对一通信
  • 消息有边界:对方发送多少次,就必须接收多少次
  • 没有流量控制:发送 0 字节时,接收端 recvfrom 不会阻塞,返回 0
  • UDP 头只有 8 字节(TCP 头 20 字节),开销更小

1.4 TCP 与 UDP 对比

特性 TCP UDP
连接方式 面向连接 无连接
可靠性 可靠传输 不可靠传输
传输方式 字节流(无边界) 数据报(有边界)
通信模式 点对点 一对一/一对多/多对一
头部大小 20 字节 8 字节
流量控制
适用场景 文件传输、Web、邮件 DNS、视频流、实时通信

二、Socket API 详解

Socket(套接字)是一种编程接口,通过返回的套接字描述符进行网络 I/O 操作,就像操作普通文件一样。

套接字类型:

  • 流式套接字(SOCK_STREAM)对应 TCP
  • 数据报套接字(SOCK_DGRAM)对应 UDP
  • 原始套接字(SOCK_RAW)跨过传输层,直接操作网络层

2.1 socket() -- 创建套接字

c 复制代码
int socket(int domain, int type, int protocol);
  • domain :地址族,常用 AF_INET(IPv4)、AF_INET6(IPv6)、AF_UNIX(本地)
  • type :套接字类型,SOCK_STREAM(TCP)或 SOCK_DGRAM(UDP)
  • protocol:协议类型,TCP/UDP 时可直接写 0
  • 返回值:套接字描述符,失败返回 -1
c 复制代码
// 创建 TCP 套接字
int tcp_sock = socket(AF_INET, SOCK_STREAM, 0);

// 创建 UDP 套接字
int udp_sock = socket(AF_INET, SOCK_DGRAM, 0);

socket() 函数实际上根据套接字类型创建发送和接收缓冲区。对于 UDP 这种数据报套接字,只创建接收缓冲区,没有发送缓冲区。

2.2 地址结构体

c 复制代码
// 通用套接字地址结构
struct sockaddr {
    sa_family_t sa_family;    // 协议族
    char        sa_data[14];  // 协议族数据
};

// IPv4 套接字地址结构
struct sockaddr_in {
    short int          sin_family;   // 协议族类型 AF_INET
    unsigned short int sin_port;     // 端口号(网络字节序)
    struct in_addr     sin_addr;     // IP 地址(网络字节序)
    unsigned char      sin_zero[8];  // 保留未用(填充)
};

struct in_addr {
    unsigned long s_addr;  // 32位 IP 地址
};

2.3 字节序转换

不同处理器采用不同的字节存储顺序,但网络传输必须使用大端字节序(网络字节序)。

c 复制代码
// 判断大小端的方法
int i = 1;
char *a = (char *)&i;
// *a == 1 则为大端,*a == 0 则为小端(x86/Intel 小端机)

字节序转换函数

函数 说明
htonl() 主机字节序 -> 网络字节序(32位)
htons() 主机字节序 -> 网络字节序(16位)
ntohl() 网络字节序 -> 主机字节序(32位)
ntohs() 网络字节序 -> 主机字节序(16位)
c 复制代码
// IP 地址转换
int inet_aton(const char *cp, struct in_addr *inp);  // 点分十进制 -> 网络字节序
char *inet_ntoa(struct in_addr in);                   // 网络字节序 -> 点分十进制

// 推荐使用的新版函数(同时支持 IPv4 和 IPv6)
int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

2.4 bind() -- 绑定地址

c 复制代码
int bind(int sockfd, const struct sockaddr *addr, socklen_t len);

将套接字绑定到指定的 IP 地址和端口上。一般来说服务器需要绑定,客户端可以让系统自动分配。

c 复制代码
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 绑定所有网卡

bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

2.5 listen() -- 监听连接

c 复制代码
int listen(int sockfd, int backlog);

listen() 将套接字变为监听套接字 ,开始等待客户端连接。backlog 参数指定未被 accept() 取走的连接最大个数。listen() 会维护两个队列(未完成连接队列和已完成连接队列),accept() 从已完成队列中取走连接。

监听套接字不会与客户端通信,只是维护连接队列。

2.6 accept() -- 接受连接

c 复制代码
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

从已完成连接队列中取出一个连接,返回一个连接套接字 ,服务端通过这个连接套接字与客户端通信。addr 参数会被填充为客户端的地址信息,addrlen 是值-结果参数。

重要connect() 发起的 TCP 三次握手在 accept() 之前就完成了。

2.7 connect() -- 发起连接

c 复制代码
int connect(int sockfd, const struct sockaddr *addr, socklen_t len);

客户端调用 connect() 向服务器发起连接请求(触发三次握手)。成功连接需要:

  • 目标机器开启并运行
  • 服务器绑定到目标地址
  • 服务器的等待连接队列有足够空间

2.8 send/recv 和 sendto/recvfrom

c 复制代码
// TCP 数据收发
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

// UDP 数据收发
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

recv 的 flags 参数常用值

  • 0:默认阻塞方式
  • MSG_DONTWAIT:非阻塞方式
  • MSG_WAITALL:等待所有数据到达
  • MSG_OOB:接收带外数据

注意事项

  • recv/read 返回 0 表示对端关闭了连接
  • send/write 即使指定了长度,实际发送的字节数可能小于指定长度
  • recvfrom 最后一个参数是地址传递(指针)
  • UDP 中不存在 SIGPIPE 信号(因为无连接)

2.9 close / shutdown

c 复制代码
close(int socket);              // 完全关闭套接字
int shutdown(int sockfd, int how);  // 更灵活的关闭方式

shutdownhow 参数:

  • SHUT_RD(0):关闭读端
  • SHUT_WR(1):关闭写端
  • SHUT_RDWR(2):关闭读写(等同于 close)

SIGPIPE 信号:如果正在读写套接字时对端关闭,会收到 SIGPIPE 信号,该信号默认会终止进程。需要编写信号处理函数来释放资源。


三、TCP 服务端/客户端完整示例

3.1 TCP 服务端

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <signal.h>
#include <sys/wait.h>

#define PORT 8080
#define BUFFER_SIZE 1024

void sigchld_handler(int sig) {
    while (waitpid(-1, NULL, WNOHANG) > 0);
}

void handle_client(int cfd) {
    char buffer[BUFFER_SIZE];
    int n;

    while ((n = recv(cfd, buffer, BUFFER_SIZE - 1, 0)) > 0) {
        buffer[n] = '\0';
        printf("收到客户端消息: %s\n", buffer);

        // 回显消息
        send(cfd, buffer, n, 0);
    }

    printf("客户端断开连接\n");
    close(cfd);
    exit(0);
}

int main() {
    int listenfd, cfd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);

    // 注册信号处理函数,回收子进程
    signal(SIGCHLD, sigchld_handler);

    // 1. 创建套接字
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) {
        perror("socket failed");
        exit(1);
    }

    // 设置地址复用
    int opt = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 2. 绑定地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    if (bind(listenfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind failed");
        exit(1);
    }

    // 3. 监听
    if (listen(listenfd, 5) < 0) {
        perror("listen failed");
        exit(1);
    }

    printf("服务器启动,监听端口 %d...\n", PORT);

    // 4. 循环接受连接
    while (1) {
        cfd = accept(listenfd, (struct sockaddr *)&client_addr, &client_len);
        if (cfd < 0) {
            perror("accept failed");
            continue;
        }

        printf("新客户端连接\n");

        // 创建子进程处理客户端请求
        pid_t pid = fork();
        if (pid == 0) {
            close(listenfd);
            handle_client(cfd);
        } else {
            close(cfd);
        }
    }

    close(listenfd);
    return 0;
}

3.2 TCP 客户端

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define SERVER_IP "127.0.0.1"
#define PORT 8080
#define BUFFER_SIZE 1024

int main() {
    int sockfd;
    struct sockaddr_in server_addr;
    char buffer[BUFFER_SIZE];

    // 1. 创建套接字
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket failed");
        exit(1);
    }

    // 2. 设置服务器地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr);

    // 3. 连接服务器
    if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("connect failed");
        exit(1);
    }

    printf("已连接到服务器 %s:%d\n", SERVER_IP, PORT);

    // 4. 通信循环
    while (1) {
        printf("请输入消息(输入quit退出): ");
        fgets(buffer, BUFFER_SIZE, stdin);

        if (strncmp(buffer, "quit", 4) == 0)
            break;

        send(sockfd, buffer, strlen(buffer), 0);

        int n = recv(sockfd, buffer, BUFFER_SIZE - 1, 0);
        if (n <= 0) {
            printf("服务器断开连接\n");
            break;
        }
        buffer[n] = '\0';
        printf("服务器回复: %s", buffer);
    }

    // 5. 关闭套接字
    close(sockfd);
    return 0;
}

TCP 服务端流程总结

  1. socket() 创建套接字
  2. bind() 绑定 IP 和端口
  3. listen() 开始监听
  4. accept() 取走连接(循环从此开始)
  5. recv() / send() 收发数据
  6. close() 关闭连接套接字

四、UDP 编程

4.1 UDP 编程流程

UDP 不需要建立连接,服务端和客户端的流程都比 TCP 简单。

复制代码
UDP服务端                          UDP客户端
-----------                        -----------
socket()                           socket()
   |                                  |
bind()(必须绑定)                       |
   |                                  |
   |              客户端请求             |
recvfrom() <--------------------- sendto()
   |                                  |
   |(处理请求)                         |
   |              服务器应答             |
sendto()   ---------------------> recvfrom()
   |                                  |
close()                           close()

4.2 UDP 完整示例

c 复制代码
/* UDP 服务端 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 9090
#define BUFFER_SIZE 1024

int main() {
    int sockfd;
    struct sockaddr_in server_addr, client_addr;
    char buffer[BUFFER_SIZE];
    socklen_t client_len = sizeof(client_addr);

    sockfd = socket(AF_INET, SOCK_DGRAM, 0);

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    printf("UDP服务器启动,监听端口 %d...\n", PORT);

    while (1) {
        int n = recvfrom(sockfd, buffer, BUFFER_SIZE - 1, 0,
                        (struct sockaddr *)&client_addr, &client_len);
        buffer[n] = '\0';

        char client_ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
        printf("收到来自 %s:%d 的消息: %s\n", client_ip, ntohs(client_addr.sin_port), buffer);

        // 回复客户端
        sendto(sockfd, buffer, n, 0,
              (struct sockaddr *)&client_addr, client_len);
    }

    close(sockfd);
    return 0;
}
c 复制代码
/* UDP 客户端 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define SERVER_IP "127.0.0.1"
#define PORT 9090
#define BUFFER_SIZE 1024

int main() {
    int sockfd;
    struct sockaddr_in server_addr;
    socklen_t server_len = sizeof(server_addr);
    char buffer[BUFFER_SIZE];

    sockfd = socket(AF_INET, SOCK_DGRAM, 0);

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr);

    while (1) {
        printf("请输入消息: ");
        fgets(buffer, BUFFER_SIZE, stdin);

        if (strncmp(buffer, "quit", 4) == 0)
            break;

        sendto(sockfd, buffer, strlen(buffer), 0,
              (struct sockaddr *)&server_addr, sizeof(server_addr));

        int n = recvfrom(sockfd, buffer, BUFFER_SIZE - 1, 0, NULL, NULL);
        buffer[n] = '\0';
        printf("服务器回复: %s", buffer);
    }

    close(sockfd);
    return 0;
}

4.3 UDP 数据传输问题

UDP 在传输过程中可能遇到以下问题:

问题 原因 解决方案
数据报丢失 网络拥塞或缓冲区溢出 设置超时检测,重发机制
数据乱序 路由器的存储转发路径不同 在数据中加入序号
缺乏流量控制 UDP 没有流量控制机制 应用层自行实现

五、I/O 复用(select / poll / epoll)

I/O 复用允许一个进程同时监控多个文件描述符,当某个描述符就绪时通知程序进行操作。注意:I/O 复用不适合处理请求非常耗时的场景。

5.1 select

select 是最早的 I/O 复用机制,通过扫描一个大整数(通常是 1024 位的 fd_set)来检测就绪的描述符。

c 复制代码
int select(int n, fd_set *read_fds, fd_set *write_fds,
           fd_set *except_fds, struct timeval *timeout);

参数说明

  • n:最大描述符 + 1
  • read_fds:关注的读描述符集合(值-结果参数)
  • write_fds:关注的写描述符集合
  • except_fds:关注的异常描述符集合(通常设 NULL)
  • timeout:超时时间,NULL 表示永久等待

返回值:>0 就绪的描述符数量,=0 超时,<0 出错

操作 fd_set 的宏

c 复制代码
FD_ZERO(fd_set *fdset)           // 清空集合
FD_SET(int fd, fd_set *fdset)    // 将 fd 加入集合
FD_CLR(int fd, fd_set *fdset)    // 将 fd 从集合移除
FD_ISSET(int fd, fd_set *fdset)  // 检查 fd 是否在集合中
c 复制代码
/* select 使用示例 */
fd_set read_fds;
int maxfd = sockfd;

while (1) {
    FD_ZERO(&read_fds);
    FD_SET(sockfd, &read_fds);     // 监听监听套接字
    FD_SET(0, &read_fds);          // 监听标准输入

    // 注意:每次调用 select 前都需要重新设置 read_fds
    int ret = select(maxfd + 1, &read_fds, NULL, NULL, NULL);
    if (ret < 0) {
        perror("select error");
        break;
    }

    if (FD_ISSET(sockfd, &read_fds)) {
        // 套接字有数据可读
        int n = recv(sockfd, buffer, sizeof(buffer), 0);
        // 处理数据...
    }

    if (FD_ISSET(0, &read_fds)) {
        // 标准输入有数据
        fgets(buffer, sizeof(buffer), stdin);
        // 处理输入...
    }
}

select 的限制 :最多支持 1024 个文件描述符(FD_SETSIZE),采用水平触发方式。

5.2 poll

pollselect 类似,但使用结构体数组代替位图,没有 1024 的数量限制。

c 复制代码
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
c 复制代码
struct pollfd {
    int   fd;       /* 文件描述符 */
    short events;   /* 关注的事件:POLLIN(可读), POLLOUT(可写) */
    short revents;  /* 返回的就绪事件 */
};
c 复制代码
/* poll 使用示例 */
#define MAX_CLIENTS 100
struct pollfd fds[MAX_CLIENTS];
int nfds = 1;

// 初始化:将未使用的描述符设为 -1
for (int i = 0; i < MAX_CLIENTS; i++)
    fds[i].fd = -1;

fds[0].fd = listenfd;
fds[0].events = POLLIN;

while (1) {
    int ret = poll(fds, nfds, -1);  // -1 表示永远等待
    if (ret < 0) {
        perror("poll error");
        break;
    }

    for (int i = 0; i < nfds; i++) {
        if (fds[i].revents & POLLIN) {
            if (fds[i].fd == listenfd) {
                // 新连接到来
                int cfd = accept(listenfd, NULL, NULL);
                fds[nfds].fd = cfd;
                fds[nfds].events = POLLIN;
                nfds++;
            } else {
                // 已有客户端有数据
                int n = recv(fds[i].fd, buffer, sizeof(buffer), 0);
                if (n <= 0) {
                    close(fds[i].fd);
                    fds[i].fd = -1;
                }
                // 处理数据...
            }
        }
    }
}

5.3 epoll(重点)

epoll 是 Linux 特有的 I/O 复用机制,是目前最高效的 I/O 复用方式 。它采用边沿触发模式,只需要设置一次就能持续监控。

epoll 三个核心函数

1) epoll_create -- 创建 epoll 实例

c 复制代码
int epoll_create(int size);
// size: 2.6.8 内核后未使用,但必须大于0
// 返回: epoll 实例描述符

2) epoll_ctl -- 注册/修改/删除事件

c 复制代码
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// op: EPOLL_CTL_ADD(添加), EPOLL_CTL_DEL(删除), EPOLL_CTL_MOD(修改)
c 复制代码
struct epoll_event {
    uint32_t     events;   /* EPOLLIN(可读), EPOLLOUT(可写), EPOLLET(边沿触发) */
    epoll_data_t data;     /* 用户数据 */
};

typedef union epoll_data {
    void     *ptr;
    int       fd;         /* 最常用:套接字描述符 */
    uint32_t  u32;
    uint64_t  u64;
} epoll_data_t;

3) epoll_wait -- 等待事件就绪

c 复制代码
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);
// 返回就绪的描述符数量
c 复制代码
/* epoll 完整使用示例 */
#include <sys/epoll.h>
#include <fcntl.h>

#define MAX_EVENTS 100

// 设置非阻塞
void set_nonblocking(int fd) {
    fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
}

int main() {
    int epfd = epoll_create(1);

    // 注册监听套接字
    struct epoll_event ev;
    ev.events = EPOLLIN;       // 关注可读事件
    ev.data.fd = listenfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

    struct epoll_event events[MAX_EVENTS];

    while (1) {
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (nfds < 0) {
            perror("epoll_wait error");
            break;
        }

        for (int i = 0; i < nfds; i++) {
            if (events[i].data.fd == listenfd) {
                // 新连接
                int cfd = accept(listenfd, NULL, NULL);
                set_nonblocking(cfd);  // 边沿触发必须非阻塞

                ev.events = EPOLLIN | EPOLLET;  // 边沿触发
                ev.data.fd = cfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
            } else {
                // 处理客户端数据
                // 边沿触发时必须一次性读完所有数据
                while (1) {
                    int n = recv(events[i].data.fd, buffer, sizeof(buffer), 0);
                    if (n > 0) {
                        // 处理数据...
                    } else if (n == 0) {
                        // 对端关闭
                        close(events[i].data.fd);
                        epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
                        break;
                    } else {
                        if (errno == EAGAIN || errno == EWOULDBLOCK)
                            break;  // 数据已读完
                    }
                }
            }
        }
    }
    close(epfd);
    return 0;
}

5.4 水平触发 vs 边沿触发

水平触发(Level Triggered) :当文件描述符上有可读写事件时,epoll_wait() 会通知处理程序。如果数据没有一次性全部读写完,下次调用时还会继续通知。select 和 poll 采用的就是水平触发。

边沿触发(Edge Triggered) :当文件描述符上有可读写事件时,epoll_wait() 只通知一次 ,直到出现第二次事件才会再次通知。这种模式比水平触发效率更高,但要求程序必须一次性读完所有数据。采用边沿触发时,必须使用非阻塞方式。

5.5 select / poll / epoll 对比

对比项 select poll epoll
最大连接数 1024 无限制 无限制
触发方式 水平触发 水平触发 支持边沿触发
内核拷贝 每次调用都拷贝 每次调用都拷贝 只设置一次
返回结果 需要遍历所有fd 需要遍历所有fd 只返回就绪的fd
效率 O(n) O(n) O(1)
适用场景 连接数少 连接数中等 连接数多(高并发)

六、5 种 I/O 模型

Linux 下共有 5 种 I/O 模型,理解它们的区别对于编写高性能网络程序至关重要。

6.1 阻塞 I/O(Blocking I/O)

最常用的 I/O 模型。在数据没到来之前,recvreadrecvfromaccept 等函数会一直阻塞。

复制代码
应用程序          内核
   |                |
   |---recv()----->| 等待数据
   |   阻塞        | 数据就绪
   |<--返回数据----| 拷贝数据
   |                |

6.2 非阻塞 I/O(Non-blocking I/O)

设置非阻塞方式(fcntl(fd, F_SETFL, O_NONBLOCK)recvMSG_DONTWAIT 标志),数据没到来时不阻塞,返回 -1 并设置 errnoEAGAIN/EWOULDBLOCK。这种模型需要轮询检测,占用 CPU 资源。

c 复制代码
// 设置非阻塞
fcntl(sockfd, F_SETFL, O_NONBLOCK);

// 轮询读取
while (1) {
    int n = recv(sockfd, buffer, sizeof(buffer), 0);
    if (n < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            // 数据未就绪,继续做其他事情
            continue;
        }
        perror("recv error");
        break;
    }
    // 处理数据...
}

6.3 I/O 复用(I/O Multiplexing)

使用 select/poll/epoll 在等待时加入超时检测,可以同时监控多个文件描述符。

复制代码
应用程序          内核
   |                |
   |--select()---->| 等待数据(可监控多个fd)
   |   阻塞        | 数据就绪
   |<--返回就绪--->|
   |---recv()----->| 拷贝数据
   |<--返回数据----|

6.4 信号驱动 I/O(Signal-driven I/O)

注册 SIGIO 信号处理函数,进程继续执行其他任务。当数据到来时,内核产生 SIGIO 信号,回调信号处理函数来接收数据。

对于 TCP 套接字,信号驱动 I/O 几乎无用(太多正常情况都会产生 SIGIO)。但对 UDP 比较有用,可以精确获取消息到达时间。

6.5 异步 I/O(Asynchronous I/O)

线程发送 I/O 请求后继续做其他事情,内核完成数据复制后才通知线程 I/O 操作已完成。

信号驱动 I/O 是数据到来时通知 ,异步 I/O 是内核完成数据复制后通知

6.6 五种模型对比

复制代码
                    阻塞在               阻塞在
                    第一阶段              第二阶段
                    (等待数据)           (拷贝数据)
阻塞I/O              是                    是
非阻塞I/O            否(轮询)              是
I/O复用              是(select/poll)       是
信号驱动I/O          否                    是
异步I/O              否                    否

七、套接字选项

7.1 getsockopt / setsockopt

c 复制代码
int getsockopt(int sockfd, int level, int optname,
               void *optval, socklen_t *optlen);
int setsockopt(int sockfd, int level, int optname,
               const void *optval, socklen_t optlen);

参数

  • level:选项级别
    • SOL_SOCKET:通用套接字选项
    • IPPROTO_IP:IP 层选项
    • IPPROTO_TCP:TCP 层选项
  • optname:选项名称
  • optval:选项值的缓冲区

7.2 常用套接字选项

1) SO_REUSEADDR -- 地址复用

允许绑定处于 TIME_WAIT 状态的地址和端口,最常用于防止服务器重启时端口未释放。

c 复制代码
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

注意 :必须在 bind() 之前调用,绑定同一端口的所有套接字都必须设置。

2) SO_RCVBUF / SO_SNDBUF -- 收发缓冲区大小

c 复制代码
int bufsize = 65536;
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize));

3) SO_RCVTIMEO / SO_SNDTIMEO -- 超时时间

c 复制代码
struct timeval tv;
tv.tv_sec = 3;
tv.tv_usec = 0;
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));

4) SO_BROADCAST -- 广播

默认关闭广播,需要手动开启:

c 复制代码
int broadcast = 1;
setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &broadcast, sizeof(broadcast));

5) SO_KEEPALIVE -- 保持连接

定期检测连接状态,适用于可能长时间没有数据的连接。

7.3 网络超时检测的三种方法

  1. 设置套接字选项SO_RCVTIMEO
  2. 利用 I/O 复用自带的超时参数:select/poll/epoll_wait 的 timeout 参数
  3. 使用定时器信号
c 复制代码
void sigfun(int sig) {
    printf("超时!\n");
    alarm(5);
}

struct sigaction act;
act.sa_handler = sigfun;
sigaction(SIGALRM, &act, NULL);
alarm(5);  // 5秒后产生 SIGALRM 信号

// 阻塞在 select/read/recv 等函数时,收到信号会返回 -1
// errno 设置为 EINTR

八、原始套接字

原始套接字(SOCK_RAW)可以跨过传输层,直接操作网络层的数据包。常用于抓包工具(如 tcpdump)、网络诊断工具和协议开发。

c 复制代码
int rawfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
// 第三个参数指定协议:IPPROTO_ICMP, IPPROTO_TCP, IPPROTO_UDP 等

通过原始套接字可以构造自定义的 IP 头和协议头,实现协议分析、网络嗅探等高级功能。


九、libevent 事件驱动编程

libevent 是一个高性能的事件驱动网络库,封装了 select/poll/epoll/kqueue 等 I/O 复用机制,提供统一的 API,让开发者无需关心底层实现。

9.1 libevent 基本使用

c 复制代码
#include <event2/event.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

void on_read(int fd, short events, void *arg) {
    char buffer[1024];
    int n = recv(fd, buffer, sizeof(buffer) - 1, 0);
    if (n <= 0) {
        close(fd);
        return;
    }
    buffer[n] = '\0';
    printf("收到: %s\n", buffer);
    send(fd, buffer, n, 0);  // 回显
}

void on_accept(int fd, short events, void *arg) {
    struct event_base *base = (struct event_base *)arg;
    struct sockaddr_in client_addr;
    socklen_t len = sizeof(client_addr);

    int cfd = accept(fd, (struct sockaddr *)&client_addr, &len);

    // 为新连接创建读事件
    struct event *ev = event_new(base, cfd, EV_READ | EV_PERSIST,
                                  on_read, NULL);
    event_add(ev, NULL);
}

int main() {
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(9999);
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    bind(listenfd, (struct sockaddr *)&addr, sizeof(addr));
    listen(listenfd, 10);

    // 创建 event_base
    struct event_base *base = event_base_new();

    // 注册监听套接字的读事件
    struct event *listen_ev = event_new(base, listenfd,
                                         EV_READ | EV_PERSIST,
                                         on_accept, base);
    event_add(listen_ev, NULL);

    printf("libevent 服务器启动,监听端口 9999...\n");

    // 进入事件循环
    event_base_dispatch(base);

    event_base_free(base);
    return 0;
}

9.2 libevent 的优势

  • 跨平台:自动选择最优的 I/O 复用机制
  • 高性能:底层使用 epoll(Linux)或 kqueue(BSD)
  • 易用性:简洁的事件驱动 API
  • 功能丰富:支持定时器、信号、缓冲事件等
  • 广泛应用:被 Chrome、Memcached、THREE 等知名项目使用

十、广播与组播

10.1 广播(Broadcast)

广播采用 UDP 协议,向同一网段中的所有主机发送数据。

c 复制代码
/* 广播发送端 */
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

// 开启广播选项
int broadcast = 1;
setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &broadcast, sizeof(broadcast));

struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(9999);
addr.sin_addr.s_addr = inet_addr("192.168.1.255");  // 广播地址

sendto(sockfd, "Hello Broadcast!", 16, 0,
       (struct sockaddr *)&addr, sizeof(addr));

10.2 组播(Multicast)

组播使用 D 类 IP 地址(224.0.0.0 ~ 239.255.255.255),实现一对多的定向通信。

c 复制代码
/* 组播接收端 -- 加入组播组 */
struct ip_mreq mreq;
mreq.imr_multiaddr.s_addr = inet_addr("224.10.10.1");
mreq.imr_interface.s_addr = htonl(INADDR_ANY);
setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq));

十一、UNIX 域套接字

UNIX 域套接字用于本地进程间通信,性能比 TCP 套接字更高。

c 复制代码
/* UNIX 域套接字服务端 */
int sockfd = socket(AF_UNIX, SOCK_STREAM, 0);

struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "/tmp/unix_socket");

unlink("/tmp/unix_socket");  // 绑定前必须删除已存在的文件
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
listen(sockfd, 5);

注意SO_REUSEADDR 选项在 UNIX 域套接字中不能使用。绑定前必须调用 unlink() 删除已存在的套接字文件。


总结

本文系统覆盖了 Linux 网络编程的核心知识体系:

  1. 协议基础:深入理解了 TCP 的三次握手/四次挥手、可靠传输机制,以及 UDP 的轻量高效特性。
  2. Socket API :掌握了从 socket()close() 的完整编程接口,包括地址结构、字节序转换等基础知识。
  3. TCP/UDP 编程:给出了完整的服务端/客户端示例代码,可以直接作为项目开发的模板。
  4. I/O 复用:对比了 select/poll/epoll 三种机制,epoll 配合边沿触发是高并发场景的最优选择。
  5. 五种 I/O 模型:理解了阻塞、非阻塞、I/O 复用、信号驱动、异步 I/O 的本质区别。
  6. 高级主题:学习了套接字选项配置、原始套接字、广播/组播、UNIX 域套接字、libevent 事件驱动编程等进阶内容。

网络编程是构建分布式系统的基础,掌握这些核心知识后,建议进一步学习 Reactor 模式、多线程网络编程框架、以及 Nginx/Redis 等开源项目的网络架构设计。


原始笔记来源:frasight/Liunx网络编程笔记.cfrasight/网络编程总结.cppfrasight/上课笔记.c(网络部分)、jdah/StudyC.c(网络部分)

相关推荐
迷途之人不知返1 小时前
deque的简单认识
数据结构·c++
zhangrelay1 小时前
ROS Kinetic-信号与系统-趣味案例
linux·笔记·学习·ubuntu
IMPYLH1 小时前
Linux 的 tail 命令
linux·运维·服务器·bash
weixin_446260851 小时前
应用实战篇:利用 DeepSeek V4 构建生产级 AI 应用的全流程与最佳实践
大数据·linux·人工智能
zhouwy1131 小时前
C++ STL标准模板库详解
c++
Nightwish52 小时前
Linux随记(三十)
linux·运维·mysql·ambari
li1670902702 小时前
第二十五章:C++11(下)
c语言·开发语言·数据结构·c++
承渊政道2 小时前
【动态规划算法】(回文串问题解题框架与经典案例)
数据结构·c++·学习·算法·leetcode·动态规划·哈希算法
AI进化营-智能译站2 小时前
ROS2 C++开发系列11-VS Code一键生成Doxygen注释|让ROS2节点文档自动跟上代码迭代
java·数据库·c++·ai