高级I/O函数:readv()、writev()、sendfile()、mmap()共享内存
各位同学,今天我们来聊聊网络编程中的几个高级I/O函数。说实话,这些函数在日常开发中用得不算特别频繁,但一旦用上,往往能解决一些棘手的问题。我个人习惯把它们称为「效率三剑客」——readv、writev、sendfile,再加上一个mmap共享内存。
你想想看,传统的read/write每次只能操作一个缓冲区。如果你要发送多个数据块,就得循环调用多次。这就像搬家,一次只能搬一个箱子,效率可想而知。而高级I/O函数,说白了就是让你一次搬多个箱子,甚至直接把箱子从仓库搬到车上,省去中间环节。
1. readv() 和 writev():分散读与集中写
这两个函数是成对出现的。readv用于从文件描述符读取数据到多个缓冲区,writev则把多个缓冲区的数据一次性写入文件描述符。我当年做高性能日志系统时,就靠它们把日志头、时间戳、消息体拼在一起发送,省去了多次系统调用。
核心优势:减少系统调用次数,降低上下文切换开销。
函数原型
#include <sys/uio.h>
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
这里的iovec结构体是关键:
struct iovec {
void *iov_base; // 缓冲区起始地址
size_t iov_len; // 缓冲区长度
};
实战示例:用writev发送HTTP响应
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/uio.h>
#include <unistd.h>
#include <sys/socket.h>
void send_http_response(int client_fd) {
// 准备三个数据块:状态行、头部、正文
char *status_line = "HTTP/1.1 200 OK\r\n";
char *headers = "Content-Type: text/html\r\nContent-Length: 13\r\n\r\n";
char *body = "<h1>Hello</h1>";
struct iovec iov[3];
iov[0].iov_base = status_line;
iov[0].iov_len = strlen(status_line);
iov[1].iov_base = headers;
iov[1].iov_len = strlen(headers);
iov[2].iov_base = body;
iov[2].iov_len = strlen(body);
// 一次系统调用,发送所有数据
ssize_t sent = writev(client_fd, iov, 3);
if (sent == -1) {
perror("writev failed");
} else {
printf("Sent %zd bytes\n", sent);
}
}
我的经验:iovcnt参数不要太大,一般不超过16。我在项目中遇到过iovcnt设置成100的情况,结果内核需要分配大量内存来拷贝这些指针,反而得不偿失。
2. sendfile():零拷贝的利器
sendfile是我最喜欢的函数之一。它可以直接把文件内容发送到socket,不需要经过用户空间缓冲区。我曾经做过一个文件下载服务器,用sendfile之后,CPU占用率从30%降到了5%以下。
函数原型
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
参数说明:
- out_fd:目标文件描述符,通常是socket
- in_fd:源文件描述符,必须是支持mmap的文件
- offset:从文件哪个位置开始读,NULL表示当前位置
- count:要传输的字节数
零拷贝原理
传统方式发送文件需要4次上下文切换和4次数据拷贝:
- read:文件→内核缓冲区→用户缓冲区
- write:用户缓冲区→内核缓冲区→socket
而sendfile只需要2次上下文切换和3次数据拷贝(甚至2次,如果硬件支持DMA):
- 文件→内核缓冲区(DMA)
- 内核缓冲区→socket缓冲区(CPU拷贝)
- socket缓冲区→网卡(DMA)
注意:sendfile的in_fd必须指向真实文件,不能是socket或管道。我曾经犯过这个错误,调试了半天才发现。
3. mmap():共享内存的基石
mmap可以把文件或设备映射到进程的地址空间。这样一来,读写文件就像读写内存一样简单。我参与过一个数据库项目,就是用mmap来管理数据文件的,性能提升非常明显。
函数原型
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
关键参数:
- prot:保护标志,常用组合:
- PROT_READ | PROT_WRITE(读写)
- PROT_READ(只读)
- flags:映射类型:
- MAP_SHARED:修改会写回文件,对其他进程可见
- MAP_PRIVATE:修改不写回文件,类似写时拷贝
- MAP_ANONYMOUS:匿名映射,不依赖文件
实战:用mmap实现进程间通信
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define SHM_SIZE 4096
int main() {
// 创建共享内存文件
int fd = open("/dev/shm/my_shm", O_CREAT | O_RDWR, 0666);
if (fd == -1) {
perror("open");
exit(1);
}
// 设置文件大小
ftruncate(fd, SHM_SIZE);
// 映射到进程地址空间
void *ptr = mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (ptr == MAP_FAILED) {
perror("mmap");
close(fd);
exit(1);
}
// 写入数据
strcpy((char *)ptr, "Hello from process A!");
printf("Data written: %s\n", (char *)ptr);
// 清理
munmap(ptr, SHM_SIZE);
close(fd);
return 0;
}
避坑指南:我曾经在mmap后忘记调用ftruncate设置文件大小,结果访问超出文件大小的内存区域时,直接触发了SIGBUS信号,进程崩溃。记住:文件大小必须大于等于映射长度。
4. 知识体系总览
下面这张图展示了这四个高级I/O函数的核心关系和应用场景:
5. 性能对比
| 函数 | 系统调用次数 | 数据拷贝次数 | 适用场景 |
|---|---|---|---|
| read/write | N次 | 4N次 | 小数据量,简单场景 |
| readv/writev | 1次 | 4次 | 多缓冲区拼接/拆分 |
| sendfile | 1次 | 2-3次 | 文件到socket传输 |
| mmap | 1次(映射时) | 1次(缺页时) | 大文件、共享内存 |
总结一下:
- 如果你需要发送多个不连续的内存块,用writev,别傻傻地循环write
- 如果你要从文件发送数据到socket,用sendfile,零拷贝真香
- 如果你需要多个进程共享数据,或者处理大文件,用mmap,但记得处理SIGBUS
- readv用得相对少一些,但在解析协议头时特别好用
嗯,这些高级I/O函数就讲到这里。说实话,刚开始学的时候我也觉得它们可有可无,但真正遇到性能瓶颈时,才发现这些函数才是解决问题的关键。记住:系统调用是昂贵的,能合并就合并,能跳过就跳过。