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); // 更灵活的关闭方式
shutdown 的 how 参数:
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 服务端流程总结:
socket()创建套接字bind()绑定 IP 和端口listen()开始监听accept()取走连接(循环从此开始)recv()/send()收发数据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:最大描述符 + 1read_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
poll 与 select 类似,但使用结构体数组代替位图,没有 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 模型。在数据没到来之前,recv、read、recvfrom、accept 等函数会一直阻塞。
应用程序 内核
| |
|---recv()----->| 等待数据
| 阻塞 | 数据就绪
|<--返回数据----| 拷贝数据
| |
6.2 非阻塞 I/O(Non-blocking I/O)
设置非阻塞方式(fcntl(fd, F_SETFL, O_NONBLOCK) 或 recv 的 MSG_DONTWAIT 标志),数据没到来时不阻塞,返回 -1 并设置 errno 为 EAGAIN/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 网络超时检测的三种方法
- 设置套接字选项 :
SO_RCVTIMEO - 利用 I/O 复用自带的超时参数:select/poll/epoll_wait 的 timeout 参数
- 使用定时器信号:
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 网络编程的核心知识体系:
- 协议基础:深入理解了 TCP 的三次握手/四次挥手、可靠传输机制,以及 UDP 的轻量高效特性。
- Socket API :掌握了从
socket()到close()的完整编程接口,包括地址结构、字节序转换等基础知识。 - TCP/UDP 编程:给出了完整的服务端/客户端示例代码,可以直接作为项目开发的模板。
- I/O 复用:对比了 select/poll/epoll 三种机制,epoll 配合边沿触发是高并发场景的最优选择。
- 五种 I/O 模型:理解了阻塞、非阻塞、I/O 复用、信号驱动、异步 I/O 的本质区别。
- 高级主题:学习了套接字选项配置、原始套接字、广播/组播、UNIX 域套接字、libevent 事件驱动编程等进阶内容。
网络编程是构建分布式系统的基础,掌握这些核心知识后,建议进一步学习 Reactor 模式、多线程网络编程框架、以及 Nginx/Redis 等开源项目的网络架构设计。
原始笔记来源:frasight/Liunx网络编程笔记.c、frasight/网络编程总结.cpp、frasight/上课笔记.c(网络部分)、jdah/StudyC.c(网络部分)