第4章:掌握缓冲与异步IO
# 第4章:掌握缓冲与异步I/O
# 概述
在现代应用程序中,提升数据处理能力和性能需要扎实掌握异步输入/输出(I/O)操作和缓冲等高级知识,本章将对这些内容展开讨论。我们首先深入研究深度缓冲机制,了解缓冲区如何管理内存和存储设备等不同系统组件之间的数据流动。你将更深入地理解有效的缓冲策略如何在提高吞吐量的同时减少延迟,确保程序高效处理大量数据。
本章接着深入探讨异步(async)I/O操作。你将学习如何进行非阻塞I/O,这能让程序在数据传输过程中处理其他任务。我们还将探索直接I/O(Direct I/O),突破数据传输的限制。最后,本章将展示如何利用异步流实现I/O性能优化,在基于流的应用程序中,将异步操作的优势与高效的数据处理相结合。
# 深入研究深度缓冲机制
在许多应用程序中,I/O操作是显著的性能瓶颈,因为磁盘和网络等设备的速度比CPU和内存慢得多。合理的缓冲区管理能使系统以更大的数据块传输数据,减少系统调用次数,从而提升性能。在本节中,我们将深入探讨缓冲的工作原理,重点关注如何调整缓冲区大小,并应用不同策略优化各类I/O操作的性能。
# 缓冲技术
根据I/O操作类型和系统的性能需求,可以采用多种缓冲策略,包括:
# 全缓冲(Full Buffering)
在全缓冲模式下,数据会一直存储在缓冲区中,直到缓冲区被填满,然后一次性写入I/O设备或从I/O设备读取。这种方式通过一次传输更大的数据块,减少了I/O操作次数,降低了频繁系统调用带来的开销。
# 行缓冲(Line Buffering)
这项技术主要用于基于文本的I/O操作,每处理完一行数据,缓冲区就会被刷新。在交互式应用程序中,比如命令行程序,每输入一行就需要立即得到反馈,此时行缓冲就很常用。
# 无缓冲(No Buffering)
在无缓冲(或称为非缓冲)I/O中,数据直接在应用程序和I/O设备之间传输。虽然这种方式能最直接地控制I/O操作,但频繁进行较小的数据传输会对性能产生负面影响。
缓冲区大小对缓冲效率起着关键作用。如果缓冲区太小,程序会频繁暂停,在缓冲区和I/O设备之间传输数据,导致开销增加。另一方面,如果缓冲区太大,会不必要地增加内存使用量,而且在刷新数据之前的延迟可能会过长。
# 示例程序:调整缓冲区大小以优化I/O
下面,我们将创建一个向文件读写数据的程序,通过调整缓冲区大小来观察其对性能的影响。我们会使用std::setvbuf()
函数,在C++中,该函数可用于控制FILE
流的缓冲模式和大小。
#include <iostream>
#include <cstdio> // For FILE and setvbuf()
#include <cstdlib> // For malloc()
#include <chrono> // For measuring performance
void write_data(FILE* file, const char* data, size_t data_size) {
size_t written = fwrite(data, sizeof(char), data_size, file);
if (written!= data_size) {
std::cerr << "Failed to write all data!" << std::endl;
}
}
int main() {
const char* file_name = "buffer_test.txt";
const char* data = "This is some data being written to the file.";
size_t data_size = std::strlen(data);
// 打开文件进行写入
FILE* file = fopen(file_name, "w");
if (!file) {
std::cerr << "Failed to open file!" << std::endl;
return 1;
}
// 将缓冲区大小设置为4KB
size_t buffer_size = 4096;
char* buffer = static_cast<char*>(std::malloc(buffer_size));
// 测量使用自定义缓冲区大小进行全缓冲所需的时间
auto start = std::chrono::high_resolution_clock::now();
if (setvbuf(file, buffer, _IOFBF, buffer_size) != 0) { // 全缓冲模式
std::cerr << "Failed to set buffer!" << std::endl;
return 1;
}
write_data(file, data, data_size);
fflush(file); // 确保数据写入磁盘
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> full_buffer_duration = end - start;
std::cout << "Full buffering (4KB) time: " << full_buffer_duration.count() << " seconds" << std::endl;
// 重置文件以进行下一次测试
fclose(file);
file = fopen(file_name, "w");
// 行缓冲测试
start = std::chrono::high_resolution_clock::now();
if (setvbuf(file, buffer, _IOLBF, buffer_size) != 0) { // 行缓冲模式
std::cerr << "Failed to set buffer!" << std::endl;
return 1;
}
write_data(file, data, data_size);
fflush(file); // 确保数据写入磁盘
end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> line_buffer_duration = end - start;
std::cout << "Line buffering (4KB) time: " << line_buffer_duration.count() << " seconds" << std::endl;
// 重置文件以进行下一次测试
fclose(file);
file = fopen(file_name, "w");
// 无缓冲测试
start = std::chrono::high_resolution_clock::now();
if (setvbuf(file, nullptr, _IONBF, 0) != 0) { // 无缓冲模式
std::cerr << "Failed to set buffer!" << std::endl;
return 1;
}
write_data(file, data, data_size);
fflush(file); // 确保数据写入磁盘
end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> no_buffer_duration = end - start;
std::cout << "No buffering time: " << no_buffer_duration.count() << " seconds" << std::endl;
// 清理
fclose(file);
std::free(buffer);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
在上述代码中:
- 程序使用
setvbuf()
函数更改FILE
流的缓冲模式。共有三种缓冲模式:_IOFBF
代表全缓冲(缓冲区仅在填满时刷新);_IOLBF
代表行缓冲(每输出一行后刷新缓冲区);_IONBF
代表无缓冲(数据直接写入I/O设备)。
- 我们使用
std::malloc()
手动分配一个4KB(4096字节)的缓冲区,并在全缓冲和行缓冲模式下将其传递给setvbuf()
函数。这样我们就能控制缓冲区大小,并评估其对性能的影响。 - 程序通过使用
std::chrono
记录每个操作的持续时间,来测量每种缓冲模式所需的时间。这有助于说明在I/O操作过程中,缓冲区大小和模式是如何影响性能的。 - 程序在每种缓冲模式下向文件写入一个短字符串。写入后,调用
fflush()
函数确保所有缓冲数据都被刷新到磁盘。
运行此程序时,你会发现根据缓冲模式的不同,性能表现也有所差异:
- 全缓冲(4KB):全缓冲通过在缓冲区中累积数据直至填满,减少了对磁盘的写入次数。对于大型I/O操作,这通常是最有效的模式,因为它降低了频繁系统调用的开销。
- 行缓冲(4KB):行缓冲会在每个换行符后强制刷新缓冲区,这在交互式应用程序中很有用,但由于写入操作更频繁,对于大型I/O操作可能会降低性能。
- 无缓冲:在无缓冲模式下,每个写入操作都会立即传输到磁盘,没有任何中间缓冲。这会导致大量的小写入操作,可能会严重降低性能,特别是在I/O是瓶颈的系统中。
# Excel异步输入/输出
# 异步输入/输出概述
异步输入/输出(也称为async I/O)允许程序在不阻塞主线程的情况下并发处理多个输入/输出任务。这在高性能和实时应用程序中非常有用,因为在这些应用场景中,诸如从文件或网络套接字进行读写的输入/输出操作,可能会因等待外部系统或资源而变得缓慢。现在,掌握异步输入/输出的关键在于理解非阻塞输入/输出(non-blocking I/O)的工作原理,我们已在上一章学习过。在这里,我们将使用select()
、poll()
和epoll()
等系统调用实现非阻塞输入/输出,以构建一个简单的异步输入/输出系统。
在深入探讨实现方法之前,我们先简要概述一下异步输入/输出背后的基本概念:
- 多路复用输入/输出操作:为了并发处理多个输入/输出流,我们使用
select()
、poll()
和epoll()
等多路复用机制。这些系统调用使我们能够同时监控多个文件描述符(代表文件、套接字等),并在其中某个文件描述符准备好进行读取或写入操作时做出响应。 - 事件驱动编程:异步输入/输出通常遵循事件驱动模型,程序等待事件(例如数据准备好读取)并相应地做出反应,而不是持续轮询或阻塞在输入/输出操作上。
# 示例程序:使用select()
实现非阻塞输入/输出
我们将从使用select()
系统调用实现一个简单的非阻塞输入/输出系统开始。select()
允许我们监控多个文件描述符(例如套接字或文件),并等待直到其中一个或多个文件描述符准备好进行输入/输出操作。一旦某个文件描述符准备就绪(例如有数据可读),我们就可以在不阻塞整个程序的情况下处理输入/输出。
在上述示例脚本中,我们将使用非阻塞输入/输出同时从多个文件描述符读取数据,模拟程序并发处理多个输入/输出流的场景。我们将使用O_NONBLOCK
标志将文件描述符设置为非阻塞模式。
#include <iostream>
#include <fcntl.h> // 用于open()和O_NONBLOCK
#include <unistd.h> // 用于read()、write()和close()
#include <sys/select.h> // 用于select()
#include <cstring> // 用于memset()
int main() {
// 以非阻塞模式打开两个文件用于读取
int fd1 = open("file1.txt", O_RDONLY | O_NONBLOCK);
int fd2 = open("file2.txt", O_RDONLY | O_NONBLOCK);
if (fd1 == -1 || fd2 == -1) {
std::cerr << "Failed to open files in non-blocking mode" << std::endl;
return 1;
}
// 为select()设置文件描述符集
fd_set readfds;
int max_fd = std::max(fd1, fd2); // 我们需要select()所需的最大文件描述符值
char buffer[256]; // 用于存储从文件读取的数据的缓冲区
bool fd1_done = false, fd2_done = false; // 用于跟踪文件是否读取完毕的标志
// 主循环,监控两个文件的读取操作
while (!fd1_done ||!fd2_done) {
FD_ZERO(&readfds); // 清空文件描述符集
if (!fd1_done) FD_SET(fd1, &readfds); // 如果fd1未读取完毕,则将其添加到文件描述符集
if (!fd2_done) FD_SET(fd2, &readfds); // 如果fd2未读取完毕,则将其添加到文件描述符集
// 使用select()等待,直到有文件描述符准备好读取
int activity = select(max_fd + 1, &readfds, nullptr, nullptr, nullptr);
if (activity == -1) {
std::cerr << "Error with select()" << std::endl;
break;
}
// 检查fd1是否准备好读取
if (FD_ISSET(fd1, &readfds)) {
ssize_t bytesRead = read(fd1, buffer, sizeof(buffer) - 1);
if (bytesRead > 0) {
buffer[bytesRead] = '\0'; // 为缓冲区添加字符串结束符
std::cout << "Read from file1.txt: " << buffer << std::endl;
} else if (bytesRead == 0) {
fd1_done = true; // 文件结束
std::cout << "file1.txt is done." << std::endl;
} else {
std::cerr << "Error reading from file1.txt" << std::endl;
}
}
// 检查fd2是否准备好读取
if (FD_ISSET(fd2, &readfds)) {
ssize_t bytesRead = read(fd2, buffer, sizeof(buffer) - 1);
if (bytesRead > 0) {
buffer[bytesRead] = '\0';
std::cout << "Read from file2.txt: " << buffer << std::endl;
} else if (bytesRead == 0) {
fd2_done = true; // 文件结束
std::cout << "file2.txt is done." << std::endl;
} else {
std::cerr << "Error reading from file2.txt" << std::endl;
}
}
}
// 清理
close(fd1);
close(fd2);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
在上述代码片段中:
- 我们使用
O_NONBLOCK
标志以非阻塞模式打开两个文件(file1.txt
和file2.txt
)。这使得程序在read()
系统调用未完成时能够继续运行。如果没有可用数据,read()
会立即返回而不阻塞。 - 然后,我们使用
select()
系统调用监控两个文件描述符(fd1
和fd2
)。select()
让我们可以检查是否有文件描述符准备好进行读取、写入或发生了错误。程序会阻塞在select()
上,直到有文件描述符准备就绪。 - 接下来,在循环中,我们使用
FD_SET()
将文件描述符添加到readfds
集合中,select()
会监控该集合以判断文件描述符是否可读。当select()
返回时,我们使用FD_ISSET()
检查哪个文件描述符已准备好。如果fd1
或fd2
准备好,我们就从相应文件读取数据并进行处理。 - 最后,
read()
系统调用尝试在不阻塞的情况下从文件读取数据。如果有数据可用,它会将数据读入缓冲区并打印结果。如果文件读取完毕(即没有更多数据可读),我们设置一个标志以停止监控该文件。
这种方法展示了如何使用非阻塞输入/输出在单线程中并发处理多个输入/输出任务,在等待数据可用时不会阻塞程序。这在服务器应用程序中特别有用,因为可能会同时有多个客户端连接,在对响应性要求很高的实时系统中也同样适用。
# 示例程序:使用网络套接字的非阻塞输入/输出
前面的示例处理的是文件,但非阻塞输入/输出在网络中尤其有用,因为与客户端和服务器的通信可能会有不可预测的延迟。这里,我们将演示如何在套接字中使用非阻塞输入/输出,使服务器能够同时处理多个客户端。
#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/select.h>
int main() {
// 创建一个TCP套接字
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
std::cerr << "Failed to create socket" << std::endl;
return 1;
}
// 将套接字绑定到一个端口
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = INADDR_ANY;
if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
std::cerr << "Failed to bind socket" << std::endl;
close(server_fd);
return 1;
}
// 监听传入的连接
if (listen(server_fd, 5) == -1) {
std::cerr << "Failed to listen on socket" << std::endl;
close(server_fd);
return 1;
}
// 将服务器套接字设置为非阻塞模式
fcntl(server_fd, F_SETFL, O_NONBLOCK);
fd_set readfds;
int max_fd = server_fd;
sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);
std::cout << "Server is listening on port 8080..." << std::endl;
// 处理传入连接和数据的主循环
while (true) {
FD_ZERO(&readfds);
FD_SET(server_fd, &readfds);
// 监控服务器套接字,等待新连接
int activity = select(max_fd + 1, &readfds, nullptr, nullptr, nullptr);
if (activity == -1) {
std::cerr << "Error with select()" << std::endl;
break;
}
// 检查服务器套接字是否准备好接受新连接
if (FD_ISSET(server_fd, &readfds)) {
int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd == -1) {
std::cerr << "Failed to accept connection" << std::endl;
} else {
// 将客户端套接字设置为非阻塞模式
fcntl(client_fd, F_SETFL, O_NONBLOCK);
std::cout << "New client connected" << std::endl;
// 处理客户端连接
close(client_fd);
}
}
}
// 关闭服务器套接字
close(server_fd);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
在上述脚本中:
- 我们创建一个TCP服务器套接字,并使用
fcntl()
和O_NONBLOCK
标志将其设置为非阻塞模式。这使服务器能够在不阻塞accept()
调用的情况下继续处理其他事务。 - 与文件示例类似,我们使用
select()
监控服务器套接字,等待新连接。当有客户端尝试连接时,select()
返回,我们调用accept()
建立连接。在非阻塞模式下,即使没有客户端等待连接,accept()
也会立即返回。 - 一旦客户端连接成功,我们也可以将客户端的套接字设置为非阻塞模式,这样服务器就可以在不阻塞任何一个客户端输入/输出的情况下处理多个客户端。
这种方法适用于可扩展的高性能服务器,能够同时处理多个客户端。非阻塞输入/输出和select()
的使用有助于在不为每个客户端分配单独线程或进程的情况下管理多个连接。
通过这些内容,我们学习了如何设计能够在高性能或实时环境中并发处理多个输入/输出任务的系统。这些技术是可扩展服务器、网络应用程序以及任何需要高效输入/输出管理的系统的基础。
# 通过直接I/O突破数据传输限制
在学习了如何管理异步I/O之后,现在我们来探讨直接I/O,以及内存映射I/O(MMIO,Memory-Mapped I/O )和零拷贝机制等先进技术,这些技术能提供更高效的数据传输方式。
# 什么是直接I/O?
在典型的I/O操作中,数据会经过操作系统的缓冲区缓存,在写入磁盘或从磁盘读取之前,数据会临时存储在那里。虽然这对大多数通用应用程序来说是高效的,但在某些情况下,绕过这些缓冲区,直接访问磁盘或设备可以显著提高性能。
直接I/O允许我们绕过操作系统的缓存机制,直接向磁盘写入数据或从磁盘读取数据。这可以减少内存使用(因为内核缓冲区中无需额外的数据副本)并提升性能,尤其是在应用程序已经自行处理缓存的情况下。
在Linux系统中,可以通过使用O_DIRECT
标志打开文件来实现直接I/O。然而,使用O_DIRECT
有特定要求,比如要确保缓冲区大小和对齐方式与设备的块大小匹配。
# 示例程序:在文件操作中使用直接I/O
我们先修改程序中的I/O操作,使用直接I/O,绕过内核缓存。
#include <iostream>
#include <fcntl.h> // 用于open()和O_DIRECT
#include <unistd.h> // 用于read()、write()、close()
#include <cstring> // 用于memset()
#include <cstdlib> // 用于posix_memalign()
int main() {
const char* filename = "direct_io_test.bin";
// 使用O_DIRECT标志打开文件以启用直接I/O
int fd = open(filename, O_RDWR | O_CREAT | O_DIRECT, 0644);
if (fd == -1) {
std::cerr << "Failed to open file with O_DIRECT" << std::endl;
return 1;
}
// 对于O_DIRECT,缓冲区大小必须与文件系统的块大小对齐
size_t block_size = 4096; // 假设块大小为4KB
char* buffer;
// 分配与块大小对齐的内存
if (posix_memalign(reinterpret_cast<void**>(&buffer), block_size, block_size) != 0) {
std::cerr << "Failed to allocate aligned buffer" << std::endl;
close(fd);
return 1;
}
// 用一些数据填充缓冲区
memset(buffer, 0xAB, block_size);
// 使用直接I/O将数据写入文件
ssize_t bytes_written = write(fd, buffer, block_size);
if (bytes_written == -1) {
std::cerr << "Failed to write using Direct I/O" << std::endl;
free(buffer);
close(fd);
return 1;
}
std::cout << "Wrote " << bytes_written << " bytes using Direct I/O" << std::endl;
// 重置文件指针
lseek(fd, 0, SEEK_SET);
// 使用直接I/O从文件读取数据
ssize_t bytes_read = read(fd, buffer, block_size);
if (bytes_read == -1) {
std::cerr << "Failed to read using Direct I/O" << std::endl;
free(buffer);
close(fd);
return 1;
}
std::cout << "Read " << bytes_read << " bytes using Direct I/O" << std::endl;
// 清理
free(buffer);
close(fd);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
在此示例中:
- 文件使用
O_DIRECT
标志打开,这启用了直接I/O。这绕过了操作系统的缓冲区缓存,直接向磁盘写入数据或从磁盘读取数据。 - 我们使用
posix_memalign()
确保缓冲区对齐。然后用一些数据(在本例中为0xAB
)填充缓冲区,以演示使用直接I/O进行写入。 - 因为我们绕过了内核缓存,所以数据直接写入磁盘并从磁盘读取。该程序展示了如何分配对齐的内存、执行操作以及之后进行清理。
这种方法避免了与缓冲区缓存相关的开销,对于像数据库这样的高性能应用程序特别有用,因为这些应用程序已经自行管理缓存或缓冲区。
# 使用内存映射I/O
内存映射I/O(MMIO)是另一种用于高效数据传输的先进技术。MMIO将文件的内容或文件的一部分直接映射到进程的内存空间中。这使你可以像访问内存一样访问文件数据,无需进行显式的读或写系统调用。
MMIO在处理大文件或数据集时特别有用,因为它无需将整个文件加载到内存中,而是按需加载页面。它还减少了多次系统调用的开销,因为你可以通过常规内存访问来访问文件。
我们修改前面的示例,如下所示,来演示如何使用MMIO:
#include <iostream>
#include <fcntl.h>
#include <sys/mman.h> // 用于mmap()和munmap()
#include <unistd.h> // 用于close()
#include <sys/stat.h> // 用于fstat()
int main() {
const char* filename = "mmap_test.bin";
// 打开文件用于读写
int fd = open(filename, O_RDWR | O_CREAT, 0644);
if (fd == -1) {
std::cerr << "Failed to open file" << std::endl;
return 1;
}
// 设置文件大小(本示例中我们使用4KB)
size_t file_size = 4096;
if (ftruncate(fd, file_size) == -1) {
std::cerr << "Failed to set file size" << std::endl;
close(fd);
return 1;
}
// 内存映射文件
void* mapped_memory = mmap(nullptr, file_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (mapped_memory == MAP_FAILED) {
std::cerr << "Failed to memory-map the file" << std::endl;
close(fd);
return 1;
}
// 向内存映射区域写入数据
std::memset(mapped_memory, 0xAA, file_size);
std::cout << "Wrote to memory-mapped file using memset" << std::endl;
// 同步对文件的更改
if (msync(mapped_memory, file_size, MS_SYNC) == -1) {
std::cerr << "Failed to sync changes to file" << std::endl;
}
// 读取回数据
char* data = reinterpret_cast<char*>(mapped_memory);
std::cout << "First byte of mapped file: 0x" << std::hex << (int)(unsigned char)data[0] << std::endl;
// 取消内存映射
if (munmap(mapped_memory, file_size) == -1) {
std::cerr << "Failed to unmap memory" << std::endl;
}
// 清理
close(fd);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
在这个程序中:
- 我们使用
mmap()
将文件映射到内存中。这使我们可以像访问进程地址空间的一部分那样访问文件。文件以读写权限打开,并且我们指定了MAP_SHARED
标志,这意味着对内存映射区域的更改会反映在文件中。 - 使用
std::memset()
,我们直接向内存映射区域写入数据。这避免了使用显式的write()
系统调用,因为对内存区域的更改会自动传播到文件中。 - 在向内存映射区域写入数据后,我们使用
msync()
确保更改被写入磁盘。如果不使用msync()
,更改可能会一直留在内存中,直到系统决定刷新它们。 - 要从文件读取数据,我们只需像访问其他任何内存块一样访问映射的内存。这允许快速、高效地访问文件内容,而无需承担I/O系统调用的开销。
最后,我们使用munmap()
取消文件的内存映射并释放内存。通过将文件映射到内存中,你可以减少多次读写系统调用的开销,并利用系统的内存管理机制实现高效的数据访问。
# 零拷贝机制
零拷贝机制旨在通过尽量减少数据在应用程序和内核之间的复制次数来消除这种开销。使用零拷贝,数据可以在I/O设备和应用程序的内存之间直接传输,从而降低CPU使用率并提高性能。零拷贝的一个常见用例是在网络套接字编程中,在这种情况下,数据需要从文件传输到网络套接字,而无需将其复制到应用程序的内存中。
在Linux上,sendfile()
系统调用就是一种零拷贝机制的示例。它允许你将数据从文件描述符直接传输到套接字,绕过用户空间内存,减少复制次数。
下面是一个示例程序,展示如何使用sendfile()
进行零拷贝文件传输:
#include <iostream>
#include <fcntl.h>
#include <sys/sendfile.h> // 用于sendfile()
#include <unistd.h> // 用于open()、close()
int main() {
const char* filename = "file_to_send.bin";
// 打开文件进行读取
int input_fd = open(filename, O_RDONLY);
if (input_fd == -1) {
std::cerr << "Failed to open file" << std::endl;
return 1;
}
// 创建一个虚拟输出文件(模拟套接字)
int output_fd = open("output_file.bin", O_WRONLY | O_CREAT, 0644);
if (output_fd == -1) {
std::cerr << "Failed to open output file" << std::endl;
close(input_fd);
return 1;
}
// 获取输入文件的大小
off_t file_size = lseek(input_fd, 0, SEEK_END);
lseek(input_fd, 0, SEEK_SET); // 将文件指针重置到开头
// 使用sendfile()将数据从输入文件传输到输出文件
ssize_t bytes_sent = sendfile(output_fd, input_fd, nullptr, file_size);
if (bytes_sent == -1) {
std::cerr << "Failed to send file using sendfile()" << std::endl;
close(input_fd);
close(output_fd);
return 1;
}
std::cout << "Sent " << bytes_sent << " bytes using zero-copy sendfile()" << std::endl;
// 清理
close(input_fd);
close(output_fd);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
在上述程序中,我们使用sendfile()
系统调用将数据从输入文件直接传输到输出文件(或网络套接字)。这避免了将数据复制到应用程序的内存中,从而实现了更快、更高效的数据传输。
所有这些先进机制,如直接I/O、内存映射I/O和零拷贝,都允许你绕过传统的I/O层,最小化开销,并实现快速、低延迟的数据传输。
# 使用异步流的I/O性能
C++20引入了协程(Coroutines),它通过允许函数暂停和恢复执行,且不会阻塞主执行线程,为异步编程提供了一种更简洁的方法。这些协程为处理异步操作(如非阻塞I/O)提供了优雅的解决方案,避免了线程或回调带来的复杂性。当与异步I/O结合使用时,协程能让我们高效地处理I/O操作,同时不会使应用程序的其他部分停止运行。
在本节中,我们将从协程的基础知识入手,然后通过实际示例展示如何使用协程进行异步I/O操作。
# 协程概述
协程的核心是能够暂停函数的执行,并在之后恢复执行。这在I/O操作中特别有用,因为等待数据可用(无论是从文件还是网络套接字获取数据)可能会很耗时。协程允许程序在等待I/O操作完成的同时继续执行其他任务。
在C++中,协程函数使用co_await
、co_yield
或co_return
关键字声明。这些关键字标记了函数中可以暂停和恢复执行的位置,具体如下:
co_await
:该关键字会暂停协程,直到等待的操作完成,然后恢复协程的执行。co_yield
:此关键字暂停协程并将一个值返回给调用者,可用于生成一系列值。co_return
:该关键字终止协程,并可选择返回一个最终值。
一个协程需要一个“承诺类型(promise type)”,这是一种返回类型,用于定义协程在暂停和恢复时的行为。在许多情况下,它可以是std::future
,但根据具体用例,也可以创建更特殊的类型。
# 示例程序:使用协程的异步I/O
我们将深入探讨一个实际示例,使用协程来执行异步文件读取操作。该程序将在不阻塞主线程的情况下异步读取文件,使得在等待I/O完成时,其他任务能够继续进行。
#include <iostream>
#include <coroutine>
#include <fstream>
#include <string>
#include <thread>
#include <future>
#include <chrono>
// A custom coroutine return type
struct AsyncRead {
struct promise_type;
using handle_type = std::coroutine_handle<promise_type>;
handle_type coro_handle;
AsyncRead(handle_type h) : coro_handle(h) {}
~AsyncRead() {
if (coro_handle) coro_handle.destroy();
}
std::string get() {
return coro_handle.promise().result;
}
struct promise_type {
std::string result;
auto get_return_object() {
return AsyncRead{handle_type::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_value(std::string value) { result = std::move(value); }
void unhandled_exception() {
std::exit(1);
}
};
};
// Simulates an asynchronous file read operation using a coroutine
AsyncRead async_read_file(const std::string& filename) {
std::ifstream file(filename);
if (!file.is_open()) {
co_return "Error: Could not open file";
}
std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
co_return content;
}
// Coroutine-aware function that waits for asynchronous I/O without blocking
std::future<void> perform_async_io(const std::string& filename) {
std::cout << "Starting asynchronous file read..." << std::endl;
AsyncRead read_result = co_await std::async(std::launch::async, async_read_file, filename);
// Simulate doing other work while file reading is happening
std::cout << "Doing other tasks while waiting for file read..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "File content read asynchronously: \n" << read_result.get() << std::endl;
}
int main() {
// Start the asynchronous I/O task
std::future<void> io_task = perform_async_io("test_file.txt");
// Simulate doing other work in the main thread
for (int i = 0; i < 5; ++i) {
std::cout << "Main thread doing work: " << i << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(500));
}
// Wait for the async task to complete
io_task.get();
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
在上述示例中,当async_read_file
协程遇到co_return
时,它会暂停执行,直到文件内容完全读取完毕。程序的其他部分继续运行,当读取操作完成后,协程恢复执行并返回结果。perform_async_io()
函数使用co_await
异步等待文件读取完成。与此同时,主线程可以自由地执行其他工作,而不会被阻塞。
# 总结
总之,本章介绍了中级和高级的输入/输出(I/O)技术,重点讨论了缓冲、异步操作和直接数据传输。本章首先深入讲解了缓冲机制,展示了不同的缓冲策略如何针对I/O操作进行优化。实际示例展示了缓冲区大小以及全缓冲、行缓冲和无缓冲等方法对数据处理效率的影响。
接着介绍了异步I/O操作,展示了非阻塞I/O如何用于同时处理多个I/O任务,而不会阻塞主执行线程。本章还展示了使用select()
和非阻塞文件描述符等机制的异步I/O,如何显著提高应用程序的响应性,特别是在实时和高性能环境中。
此外,本章讨论了使用直接I/O(Direct I/O)绕过传统I/O层,从而实现更快、更高效的数据传输。最后,本章介绍了如何使用协程执行异步I/O,同时避免主线程崩溃。这些方法为开发具有高响应性、高效的系统提供了思路,使其能够处理复杂的I/O工作负载。