Linux高性能服务器编程
参考
Linux高性能服务器编程源码: https://github.com/raichen/LinuxServerCodes
豆瓣: Linux高性能服务器编程
文章目录
- Linux高性能服务器编程
- 第06章 高级I/O函数
- 6.1 pipe函数
- 6.2 dup函数和dup2函数
- 6.3 readv 函数和writev 函数
- 6.4 sendfile 函数
- 6.5 mmap 函数和munmap函数
- 6.6 splice 函数
- 6.7 tee函数
- 6.8 fcntl函数
- 后记
第06章 高级I/O函数
Linux提供了很多高级的I/O函数。它们并不像Linux基础I/O函数(比如open和read) 那么常用(编写内核模块时一般要实现这些I/O函数),但在特定的条件下却表现出优秀的性 能。本章将讨论其中和网络编程相关的几个,这些函数大致分为三类:
用于创建文件描述符的函数,包括pipe、dup/dup2函数。
用于读写数据的函数,包括readv/writev、sendfile、mmap/munmap、splice和tee函数。
用于控制I/O行为和属性的函数,包括fcntl函数。
6.1 pipe函数
pipe函数可用于创建一个管道,以实现进程间通信。我们将在13.4节讨论如何使用管道 来实现进程间通信,本章只介绍其基本使用方式。pipe函数的定义如下:
#include <unistd.h>
int pipe( int fd[2] );
pipe函数的参数是一个包含两个int型整数的数组指针。该函数成功时返回0,并将一对 打开的文件描述符值填入其参数指向的数组。如果失败,则返回-1并设置errno。
通过pipe函数创建的这两个文件描述符fd[0]和fd[1]分别构成管道的两端,往fd[1]写入的数据可以从fd[0]读出。并且,fd[0]只能用于从管道读出数据,fd[1]则只能用于往管道 写入数据,而不能反过来使用。如果要实现双向的数据传输,就应该使用两个管道。默认情况下,这一对文件描述符都是阻塞的。此时如果我们用read系统调用来读取一个空的管道, 则read将被阻塞,直到管道内有数据可读;如果我们用write系统调用来往一个满的管道(见 后文)中写入数据,则write亦将被阻塞,直到管道有足够多的空闲空间可用。但如果应用 程序将fd[0]和fd[1]都设置为非阻塞的,则read和write会有不同的行为。关于阻塞和非阻 塞的讨论,见第8章。如果管道的写端文件描述符fd[1]的引用计数(见5.7节)减少至0, 即没有任何进程需要往管道中写入数据,则针对该管道的读端文件描述符fd[0]的read操作 将返回0,即读取到了文件结束标记(End Of File,EOF);反之,如果管道的读端文件描述 符fd[0]的引用计数减少至0,即没有任何进程需要从管道读取数据,则针对该管道的写端文 件描述符fd[1]的write操作将失败,并引发SIGPIPE信号。关于SIGPIPE信号,我们将在 第10章讨论。
pipe
函数是用于创建管道的系统调用。管道是用于进程间通信的一种机制,它可以在两个进程之间传递数据。pipe
函数的声明如下:
#include <unistd.h>
int pipe(int fd[2]);
-
参数:
fd
: 用于存储管道两端文件描述符的数组。fd[0]
是用于读取的文件描述符,fd[1]
是用于写入的文件描述符。
-
返回值:
- 如果成功,返回 0;如果失败,返回 -1,并设置
errno
。
- 如果成功,返回 0;如果失败,返回 -1,并设置
使用示例:
#include <stdio.h>
#include <unistd.h>
int main() {
int pipe_fd[2];
// 创建管道
if (pipe(pipe_fd) == -1) {
perror("pipe");
return 1;
}
// 管道创建成功,pipe_fd[0] 用于读取,pipe_fd[1] 用于写入
// 关闭不需要的文件描述符
close(pipe_fd[0]); // 关闭读取端
close(pipe_fd[1]); // 关闭写入端
return 0;
}
上述示例演示了如何使用 pipe
函数创建一个管道。创建成功后,pipe_fd[0]
用于读取,pipe_fd[1]
用于写入。通常,创建管道后,需要在进程中关闭不需要的文件描述符。
管道内部传输的数据是字节流,这和TCP字节流的概念相同。但二者又有细微的区别。应用层程序能往一个TCP连接中写入多少字节的数据,取决于对方的接收通告窗口的大小和 本端的拥塞窗口的大小。而管道本身拥有一个容量限制,它规定如果应用程序不将数据从管道读走的话,该管道最多能被写入多少字节的数据。自Linux2.6.11内核起,管道容量的大 小默认是65536字节。我们可以使用fcntl函数来修改管道容量(见后文)。此外,socket的基础API中有一个socketpair 函数。它能够方便地创建双向管道。其定义如下:
#include<sys/types.h>
#include<sys/socket.h>
int socketpair(int domain, int type, int protocol, int fd[2] );
socketpair 前三个参数的含义与socket系统调用的三个参数完全相同,但domain 只能使 用UNIX本地域协议族AF_UNIX,因为我们仅能在本地使用这个双向管道。最后一个参数 则和pipe系统调用的参数一样,只不过socketpair创建的这对文件描述符都是既可读又可写 的。socketpair 成功时返回0,失败时返回-1并设置errno。
6.2 dup函数和dup2函数
有时我们希望把标准输入重定向到一个文件,或者把标准输出重定向到一个网络连接 (比如CGI编程)。这可以通过下面的用于复制文件描述符的dup或dup2函数来实现:
#include <unistd.h>
int dup( int flle_descriptor );
int dup2( int file_descriptor_one, int file_descriptor_two );
dup函数创建一个新的文件描述符,该新文件描述符和原有文件描述符file_descriptor指 向相同的文件、管道或者网络连接。并且dup返回的文件描述符总是取系统当前可用的最小 整数值。dup2和dup类似,不过它将返回第一个不小于file_descriptor_two的整数值。dup和 dup2系统调用失败时返回-1并设置errno。
6-1testdup.cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
int main(int argc, char* argv[]) {
// 检查命令行参数是否足够
if (argc <= 2) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char* ip = argv[1];
int port = atoi(argv[2]);
// 初始化服务器地址结构
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
// 创建套接字
int sock = socket(PF_INET, SOCK_STREAM, 0);
assert(sock >= 0);
// 绑定地址
int ret = bind(sock, (struct sockaddr*)&address, sizeof(address));
assert(ret != -1);
// 监听连接
ret = listen(sock, 5);
assert(ret != -1);
// 等待客户端连接
struct sockaddr_in client;
socklen_t client_addrlength = sizeof(client);
int connfd = accept(sock, (struct sockaddr*)&client, &client_addrlength);
if (connfd < 0) {
printf("errno is: %d\n", errno);
} else {
// 关闭标准输出文件描述符
close(STDOUT_FILENO);
// 复制 connfd 到标准输出文件描述符的位置
dup(connfd);
// 此后标准输出将输出到 connfd 关联的套接字
printf("abcd\n");
// 关闭 connfd
close(connfd);
}
// 关闭套接字
close(sock);
return 0;
}
这段代码的主要作用是创建一个服务器程序,监听指定端口,并在接收到客户端连接后将标准输出重定向到与客户端连接关联的套接字。
-
socket 创建:通过
socket
函数创建一个套接字,用于接收客户端连接。 -
bind 和 listen:使用
bind
绑定地址,然后通过listen
监听连接。 -
accept:等待客户端连接,一旦有客户端连接,就会返回一个新的套接字
connfd
。 -
dup 函数:关闭标准输出文件描述符 (
STDOUT_FILENO
),然后使用dup
函数将connfd
复制到标准输出文件描述符的位置。这样,之后所有的printf
输出都将写入到与客户端连接关联的套接字。 -
输出到客户端:通过
printf
输出 “abcd”,这将通过与客户端连接的套接字发送给客户端。 -
关闭套接字:关闭套接字,释放资源。
总体来说,这段代码演示了如何将标准输出重定向到与客户端连接的套接字,从而实现通过网络连接输出信息到客户端。
在代码清单6-1中,我们先关闭标准输出文件描述符STDOUT_FILENO(其值是1), 然后复制socket文件描述符connfd。因为dup总是返回系统中最小的可用文件描述符,所以 它的返回值实际上是1,即之前关闭的标准输出文件描述符的值。这样一来,服务器输出到 标准输出的内容(这里是“abcd”)就会直接发送到与客户连接对应的socket上,因此printf 调用的输出将被客户端获得(而不是显示在服务器程序的终端上)。这就是CGl服务器的基 本工作原理。
这段话描述了CGI服务器的基本工作原理。下面是对每个步骤的解释:
-
关闭标准输出文件描述符 (
STDOUT_FILENO
): 通过调用close(STDOUT_FILENO)
关闭标准输出文件描述符。这是因为在CGI服务器的工作模式中,我们希望将动态生成的内容发送到与客户端连接相关联的套接字,而不是输出到服务器程序的终端。 -
复制socket文件描述符 (
connfd
): 使用dup(connfd)
将套接字文件描述符connfd
复制到系统中最小的可用文件描述符,而这个最小的可用文件描述符实际上就是关闭的标准输出文件描述符STDOUT_FILENO
的值。这意味着现在套接字文件描述符connfd
成为了标准输出文件描述符的副本。 -
输出到标准输出: 使用
printf
输出内容(在这里是 “abcd”)。由于标准输出文件描述符已经被复制为与客户端连接相关的套接字,所以printf
的输出实际上会被发送到客户端而不是显示在服务器程序的终端上。 -
客户端接收: 因为标准输出已被重定向到与客户端连接的套接字,所以客户端将接收到服务器发送的 “abcd”。
总体而言,CGI服务器通过关闭标准输出,将套接字文件描述符复制到标准输出的位置,然后通过标准输出输出内容,实现了将动态生成的内容发送到与客户端连接相关的套接字,从而向客户端提供实时的动态内容。这是基本的CGI服务器工作原理。
6.3 readv 函数和writev 函数
readv函数将数据从文件描述符读到分散的内存块中,即分散读;writev函数则将多块分散的内存数据一并写入文件描述符中,即集中写。它们的定义如下:
#include <sys/uio.h>
ssize_t readv( int fd, const struct iovec* vector, int count);
ssize_t writev( int fd, const struct iovec* vector, int count );
fd参数是被操作的目标文件描述符。vector参数的类型是iovec结构数组。我们在第5 章讨论过结构体iovec,该结构体描述一块内存区。count参数是vector数组的长度,即有多 少块内存数据需要从fd读出或写到fd。readv和writev在成功时返回读出/写入fd的字节数,失败则返回-1并设置errno。它们相当于简化版的recvmsg和sendmsg函数。
考虑第4章讨论过的Web服务器。当Web服务器解析完一个HTTP请求之后,如果目标文档存在且客户具有读取该文档的权限,那么它就需要发送一个HTTP应答来传输该文档。这个HTTP应答包含1个状态行、多个头部字段、1个空行和文档的内容。其中,前3部分的内容可能被Web服务器放置在一块内存中,而文档的内容则通常被读入到另外一块单 独的内存中(通过read函数或mmap函数)。我们并不需要把这两部分内容拼接到一起再发送,而是可以使用writev函数将它们同时写出,如代码清单6-2所示。
6-2testwritev.cpp
这段代码是一个简单的HTTP服务器,根据客户端请求的文件名,在响应中返回相应的文件内容。以下是对代码的注释和解释:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#define BUFFER_SIZE 1024
static const char* status_line[2] = { "200 OK", "500 Internal server error" };
int main( int argc, char* argv[] )
{
// 检查命令行参数
if( argc <= 3 )
{
printf( "usage: %s ip_address port_number filename\n", basename( argv[0] ) );
return 1;
}
const char* ip = argv[1];
int port = atoi( argv[2] );
const char* file_name = argv[3];
// 创建套接字
struct sockaddr_in address;
bzero( &address, sizeof( address ) );
address.sin_family = AF_INET;
inet_pton( AF_INET, ip, &address.sin_addr );
address.sin_port = htons( port );
int sock = socket( PF_INET, SOCK_STREAM, 0 );
assert( sock >= 0 );
// 绑定地址
int ret = bind( sock, ( struct sockaddr* )&address, sizeof( address ) );
assert( ret != -1 );
// 监听
ret = listen( sock, 5 );
assert( ret != -1 );
// 接受客户端连接
struct sockaddr_in client;
socklen_t client_addrlength = sizeof( client );
int connfd = accept( sock, ( struct sockaddr* )&client, &client_addrlength );
if ( connfd < 0 )
{
printf( "errno is: %d\n", errno );
}
else
{
// 处理HTTP响应
char header_buf[ BUFFER_SIZE ];
memset( header_buf, '\0', BUFFER_SIZE );
char* file_buf;
struct stat file_stat;
bool valid = true;
int len = 0;
// 检查文件状态
if( stat( file_name, &file_stat ) < 0 )
{
valid = false;
}
else
{
// 检查是否为目录,是否有读权限
if( S_ISDIR( file_stat.st_mode ) || !(file_stat.st_mode & S_IROTH) )
{
valid = false;
}
else
{
// 读取文件内容
int fd = open( file_name, O_RDONLY );
file_buf = new char [ file_stat.st_size + 1 ];
memset( file_buf, '\0', file_stat.st_size + 1 );
if ( read( fd, file_buf, file_stat.st_size ) < 0 )
{
valid = false;
}
}
}
if( valid )
{
// 构建HTTP响应头
ret = snprintf( header_buf, BUFFER_SIZE-1, "%s %s\r\n", "HTTP/1.1", status_line[0] );
len += ret;
ret = snprintf( header_buf + len, BUFFER_SIZE-1-len,
"Content-Length: %d\r\n", file_stat.st_size );
len += ret;
ret = snprintf( header_buf + len, BUFFER_SIZE-1-len, "%s", "\r\n" );
struct iovec iv[2];
iv[ 0 ].iov_base = header_buf;
iv[ 0 ].iov_len = strlen( header_buf );
iv[ 1 ].iov_base = file_buf;
iv[ 1 ].iov_len = file_stat.st_size;
// 使用 writev 函数将响应头和文件内容一并写入套接字
ret = writev( connfd, iv, 2 );
}
else
{
// 发送500错误响应
ret = snprintf( header_buf, BUFFER_SIZE-1, "%s %s\r\n", "HTTP/1.1", status_line[1] );
len += ret;
ret = snprintf( header_buf + len, BUFFER_SIZE-1-len, "%s", "\r\n" );
send( connfd, header_buf, strlen( header_buf ), 0 );
}
// 关闭连接并释放资源
close( connfd );
delete [] file_buf;
}
// 关闭服务器套接字
close( sock );
return 0;
}
这个程序根据客户端请求的文件名,返回相应的HTTP响应。它能处理的请求包括:
- 如果请求的文件存在且可读,返回一个包含文件内容的200 OK响应。
- 如果请求的文件是目录或者不可读,返回一个500 Internal Server Error响应。
代码清单6-2中,我们省略了HTTP请求的接收及解析,因为现在关注的重点是HTTP 应答的发送。我们直接将目标文件作为第3个参数传递给服务器程序,客户telnet到该服务 器上即可获得该文件。关于HTTP请求的解析,我们将在第8章给出相关代码。
6.4 sendfile 函数
sendfile函数在两个文件描述符之间直接传递数据(完全在内核中操作),从而避免了内核缓冲区和用户缓冲区之间的数据拷贝,效率很高,这被称为零拷贝。sendfile函数的定义如下:
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t* offset, size_t count );
in_fd参数是待读出内容的文件描述符,out_fd参数是待写入内容的文件描述符。offset参数指定从读入文件流的哪个位置开始读,如果为空,则使用读入文件流默认的起始位置。 count参数指定在文件描述符in_fd和out_fd之间传输的字节数。sendfile 成功时返回传输的 字节数,失败则返回-1并设置errno。该函数的man手册明确指出,in_fd必须是一个支持 类似mmap函数的文件描述符,即它必须指向真实的文件,不能是socket和管道;而out fd 则必须是一个socket。由此可见,sendfile几乎是专门为在网络上传输文件而设计的。下面的 代码清单6-3利用sendfile函数将服务器上的一个文件传送给客户端。
以下是一个简单的使用 sendfile
函数的代码示例。该示例将一个文件的内容写入到套接字中。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/sendfile.h>
#include <unistd.h>
int main() {
// 打开源文件
int in_fd = open("source.txt", O_RDONLY);
if (in_fd == -1) {
perror("Error opening source file");
return 1;
}
// 创建套接字并绑定端口
int sock = socket(AF_INET, SOCK_STREAM, 0);
// 省略套接字创建和绑定的代码
// 打开目标文件(套接字)
int out_fd = accept(sock, NULL, NULL);
if (out_fd == -1) {
perror("Error accepting connection");
close(in_fd);
close(sock);
return 1;
}
// 获取源文件的大小
struct stat stat_buf;
fstat(in_fd, &stat_buf);
// 使用 sendfile 将文件内容传输到套接字
off_t offset = 0;
ssize_t sent_bytes = sendfile(out_fd, in_fd, &offset, stat_buf.st_size);
if (sent_bytes == -1) {
perror("Error using sendfile");
}
// 关闭文件和套接字
close(in_fd);
close(out_fd);
close(sock);
return 0;
}
请注意,上述代码是一个简化的示例,实际应用中可能需要更多的错误检查和处理。
上述代码中的
struct stat stat_buf;
fstat(in_fd, &stat_buf);
解释如下:
struct stat stat_buf;
声明了一个结构体变量 stat_buf
,该结构体用于存储文件的状态信息,包括文件大小、权限、最后访问时间等。fstat(in_fd, &stat_buf);
通过文件描述符 in_fd
获取文件状态信息,并将其保存在 stat_buf
中。
具体而言,fstat
函数的作用是获取与文件描述符相关联的文件的状态信息,并将这些信息填充到传入的结构体中。在这里,fstat
函数用于获取打开的源文件 in_fd
的状态信息,以便后续操作,如获取文件大小等。
struct stat
结构体的定义通常包含了很多字段,例如:
struct stat {
dev_t st_dev; /* 文件所在设备的 ID */
ino_t st_ino; /* 文件的 inode 号 */
mode_t st_mode; /* 文件的类型和权限信息 */
nlink_t st_nlink; /* 文件的硬链接数量 */
uid_t st_uid; /* 文件的用户 ID */
gid_t st_gid; /* 文件的组 ID */
off_t st_size; /* 文件的大小(字节数)*/
time_t st_atime; /* 最后访问时间 */
time_t st_mtime; /* 最后修改时间 */
time_t st_ctime; /* 最后状态改变时间 */
blksize_t st_blksize; /* 文件系统 I/O 缓冲区大小 */
blkcnt_t st_blocks; /* 分配给文件的块数量 */
};
在上述代码中,st_size
字段用于获取文件的大小(字节数),这对于确定文件的长度非常有用。
6-3testsendfile.cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/sendfile.h>
int main( int argc, char* argv[] )
{
// 检查命令行参数
if( argc <= 3 )
{
printf( "usage: %s ip_address port_number filename\n", basename( argv[0] ) );
return 1;
}
// 获取命令行参数
const char* ip = argv[1];
int port = atoi( argv[2] );
const char* file_name = argv[3];
// 打开文件
int filefd = open( file_name, O_RDONLY );
assert( filefd > 0 );
// 获取文件状态信息
struct stat stat_buf;
fstat( filefd, &stat_buf );
// 创建服务器地址结构
struct sockaddr_in address;
bzero( &address, sizeof( address ) );
address.sin_family = AF_INET;
inet_pton( AF_INET, ip, &address.sin_addr );
address.sin_port = htons( port );
// 创建监听socket
int sock = socket( PF_INET, SOCK_STREAM, 0 );
assert( sock >= 0 );
// 绑定地址
int ret = bind( sock, ( struct sockaddr* )&address, sizeof( address ) );
assert( ret != -1 );
// 监听
ret = listen( sock, 5 );
assert( ret != -1 );
// 接受客户端连接
struct sockaddr_in client;
socklen_t client_addrlength = sizeof( client );
int connfd = accept( sock, ( struct sockaddr* )&client, &client_addrlength );
if ( connfd < 0 )
{
printf( "errno is: %d\n", errno );
}
else
{
// 使用sendfile发送文件内容
sendfile( connfd, filefd, NULL, stat_buf.st_size );
// 关闭连接
close( connfd );
}
// 关闭监听socket
close( sock );
return 0;
}
代码清单6-3中,我们将目标文件作为第3个参数传递给服务器程序,客户telnet到该服 务器上即可获得该文件。相比代码清单6-2,代码清单6-3没有为目标文件分配任何用户空间 的缓存,也没有执行读取文件的操作,但同样实现了文件的发送,其效率显然要高得多。
6.5 mmap 函数和munmap函数
mmap函数用于申请一段内存空间。我们可以将这段内存作为进程间通信的共享内存, 也可以将文件直接映射到其中。munmap函数则释放由mmap创建的这段内存空间。它们的 定义如下:
#include <sys/mman.h>
void* mmap( void *start, size_t length, int prot, int flags, int fd, off_t offset );
int munmap( void *start, size_t length );
start参数允许用户使用某个特定的地址作为这段内存的起始地址。如果它被设置成 NULL,则系统自动分配一个地址。length参数指定内存段的长度。prot参数用来设置内存段的访问权限。它可以取以下几个值的按位或:
PROT_READ,内存段可读。
PROT_WRITE,内存段可写。
PROT_EXEC,内存段可执行。
PROT NONE,内存段不能被访问。
flags参数控制内存段内容被修改后程序的行为。它可以被设置为表6-1中的某些值(这 里仅列出了常用的值)的按位或(其中MAP_SHARED和MAP_PRIVATE是互斥的,不能同时指定)。
fd参数是被映射文件对应的文件描述符。它一般通过open系统调用获得。offset参数设 置从文件的何处开始映射(对于不需要读入整个文件的情况)。mmap函数成功时返回指向目标内存区域的指针,失败则返回MAP_FAILED((void*)-1)并设置errno。munmap函数成功时返回0,失败则返回-1并设置errno。我们将在第13章进一步讨论如何利用mmap 函数实现进程间共享内存。
mmap
函数用于将一个文件或者其它对象映射到调用进程的地址空间,而 munmap
函数用于解除这种映射关系。
-
mmap
函数参数解释:start
: 指定映射的起始地址,通常设置为0,由系统自动分配。length
: 映射的长度。prot
: 保护标志,指定映射区的保护方式,可以是PROT_NONE
(不可访问),PROT_READ
(可读),PROT_WRITE
(可写),PROT_EXEC
(可执行)等。flags
: 映射区的类型和映射对象的处理方式,可以是MAP_SHARED
(共享映射)或MAP_PRIVATE
(私有映射)等。fd
: 文件描述符,映射的文件。offset
: 文件映射的起始位置。
-
munmap
函数参数解释:start
: 映射区的起始地址。length
: 映射区的长度。
以下是一个简单的代码示例:
#include <sys/mman.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main() {
const char* file_path = "example.txt";
const size_t file_size = 4096;
// 打开文件
int fd = open(file_path, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
if (fd == -1) {
perror("open");
return 1;
}
// 调整文件大小
if (ftruncate(fd, file_size) == -1) {
perror("ftruncate");
close(fd);
return 1;
}
// 映射文件到内存
void* mapped_data = mmap(NULL, file_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (mapped_data == MAP_FAILED) {
perror("mmap");
close(fd);
return 1;
}
// 将数据写入映射区
const char* message = "Hello, Memory-mapped File!";
strncpy(mapped_data, message, strlen(message));
// 解除映射关系
if (munmap(mapped_data, file_size) == -1) {
perror("munmap");
}
// 关闭文件
close(fd);
return 0;
}
此示例创建了一个文件,通过 mmap
将文件映射到内存中,然后写入数据,最后通过 munmap
解除映射关系。
6.6 splice 函数
splice函数用于在两个文件描述符之间移动数据,也是零拷贝操作。splice函数的定义如下:
#include <fcntl.h>
ssize_t splice( int fd_in, loff_t* off_in, int fd_out, loff_t* off_out,size_t len, unsigned int flags );
fd_in参数是待输入数据的文件描述符。如果fd_in是一个管道文件描述符,那么off_in 参数必须被设置为NULL。如果fd_in不是一个管道文件描述符(比如socket),那么off_in表示从输入数据流的何处开始读取数据。此时,若off_in被设置为NULL,则表示从输入数据流的当前偏移位置读入;若off_in不为NULL,则它将指出具体的偏移位置。fd_out/off_ out参数的含义与fd_in/off_in相同,不过用于输出数据流。len参数指定移动数据的长度; flags参数则控制数据如何移动,它可以被设置为表6-2中的某些值的按位或。
使用 splice 函数时, fd_in和fd_out 必须至少有一个是管道文件描述符。splice 函数调 用成功时返回移动字节的数量。它可能返回0,表示没有数据需要移动,这发生在从管道中 读取数据(fd_in是管道文件描述符)而该管道没有被写入任何数据时。splice函数失败时返 回-1并设置errmo。常见的errno如表6-3所示。
下面我们使用splice函数来实现一个零拷贝的回射服务器,它将客户端发送的数据原样 返回给客户端,具体实现如代码清单6-4所示。
6-4testsplice.cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
int main( int argc, char* argv[] )
{
if( argc <= 2 )
{
printf( "usage: %s ip_address port_number\n", basename( argv[0] ) );
return 1;
}
const char* ip = argv[1];
int port = atoi( argv[2] );
// 创建套接字
struct sockaddr_in address;
bzero( &address, sizeof( address ) );
address.sin_family = AF_INET;
inet_pton( AF_INET, ip, &address.sin_addr );
address.sin_port = htons( port );
int sock = socket( PF_INET, SOCK_STREAM, 0 );
assert( sock >= 0 );
// 绑定套接字
int ret = bind( sock, ( struct sockaddr* )&address, sizeof( address ) );
assert( ret != -1 );
// 监听套接字
ret = listen( sock, 5 );
assert( ret != -1 );
// 接受客户端连接
struct sockaddr_in client;
socklen_t client_addrlength = sizeof( client );
int connfd = accept( sock, ( struct sockaddr* )&client, &client_addrlength );
if ( connfd < 0 )
{
printf( "errno is: %d\n", errno );
}
else
{
// 创建管道
int pipefd[2];
ret = pipe( pipefd );
assert( ret != -1 );
// 从套接字读取数据并通过 splice 复制到管道
ret = splice( connfd, NULL, pipefd[1], NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE );
assert( ret != -1 );
// 从管道读取数据并通过 splice 复制到套接字
ret = splice( pipefd[0], NULL, connfd, NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE );
assert( ret != -1 );
// 关闭连接套接字
close( connfd );
}
// 关闭监听套接字
close( sock );
return 0;
}
注释和解释:
-
创建套接字、绑定、监听:通过
socket
、bind
、listen
创建并设置服务器套接字。 -
接受客户端连接:使用
accept
函数等待客户端连接,得到连接套接字connfd
。 -
创建管道:使用
pipe
创建一个管道,pipefd[0]
是读取端,pipefd[1]
是写入端。 -
通过
splice
实现数据传输:使用两次splice
函数,第一次从连接套接字connfd
中读取数据并写入管道,第二次从管道读取数据并写入连接套接字。这样实现了零拷贝,避免了数据在用户空间和内核空间之间的复制。 -
关闭连接套接字:关闭已经处理完的连接套接字。
-
关闭监听套接字:关闭服务器监听套接字。
我们通过splice函数将客户端的内容读入到pipefd[1]中,然后再使用splice 函数从 pipefd[0]中读出该内容到客户端,从而实现了简单高效的回射服务。整个过程未执行recv/ send操作,因此也未涉及用户空间和内核空间之间的数据拷贝。
6.7 tee函数
tee函数在两个管道文件描述符之间复制数据,也是零拷贝操作。它不消耗数据,因此 源文件描述符上的数据仍然可以用于后续的读操作。tee函数的原型如下:
#include <fcntl.h>
ssize_t tee( int fd_in, int fd_out, size_t len, unsigned int flags );
该函数的参数的含义与splice相同(但fd_in和fd_out 必须都是管道文件描述符)。tee 函数成功时返回在两个文件描述符之间复制的数据数量(字节数)。返回0表示没有复制任 何数据。tee失败时返回-1并设置errno。
代码清单6-5利用tee 函数和splice函数,实现了Linux下tee程序(同时输出数据到终 端和文件的程序,不要和tee函数混淆)的基本功能。
6-5testtee.cpp
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
int main( int argc, char* argv[] )
{
// 检查命令行参数是否合法
if ( argc != 2 )
{
printf( "usage: %s <file>\n", argv[0] );
return 1;
}
// 打开文件,若文件不存在则创建
int filefd = open( argv[1], O_CREAT | O_WRONLY | O_TRUNC, 0666 );
assert( filefd > 0 );
// 创建两个管道,分别用于标准输入、文件写入和标准输出
int pipefd_stdout[2];
int ret = pipe( pipefd_stdout );
assert( ret != -1 );
int pipefd_file[2];
ret = pipe( pipefd_file );
assert( ret != -1 );
// 使用 splice 将标准输入的内容写入 pipefd_stdout[1] 管道
ret = splice( STDIN_FILENO, NULL, pipefd_stdout[1], NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE );
assert( ret != -1 );
// 使用 tee 函数将 pipefd_stdout[0] 管道的内容同时写入 pipefd_file[1] 管道和标准输出
ret = tee( pipefd_stdout[0], pipefd_file[1], 32768, SPLICE_F_NONBLOCK );
assert( ret != -1 );
// 使用 splice 将 pipefd_file[0] 管道的内容写入文件
ret = splice( pipefd_file[0], NULL, filefd, NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE );
assert( ret != -1 );
// 使用 splice 将 pipefd_stdout[0] 管道的内容写入标准输出
ret = splice( pipefd_stdout[0], NULL, STDOUT_FILENO, NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE );
assert( ret != -1 );
// 关闭文件和所有使用的管道
close( filefd );
close( pipefd_stdout[0] );
close( pipefd_stdout[1] );
close( pipefd_file[0] );
close( pipefd_file[1] );
return 0;
}
作用: 该程序通过 splice
和 tee
函数实现了将标准输入的内容同时写入文件和标准输出的功能。使用管道和文件描述符传输数据,无需用户空间和内核空间之间的数据拷贝,从而提高了效率。
6.8 fcntl函数
fcntl 函数,正如其名字(file control)描述的那样,提供了对文件描述符的各种控制操 作。另外一个常见的控制文件描述符属性和行为的系统调用是ioctl,而且ioctl比fcntl能够 执行更多的控制。但是,对于控制文件描述符常用的属性和行为,fcntl函数是由POSIX规 范指定的首选方法。所以本书仅讨论fcntl 函数。fcntl函数的定义如下:
#include <fcntl.h>
int fcntl( int fd, int cmd, … );
fd参数是被操作的文件描述符,cmd参数指定执行何种类型的操作。根据操作类型的不 同,该函数可能还需要第三个可选参数arg。
#include <fcntl.h>
int fcntl( int fd, int cmd, ... );
解释:
fd
: 文件描述符,是需要进行操作的文件或套接字的标识符。cmd
: 控制命令,指定对文件描述符fd
进行的操作。
fcntl
函数用于对文件描述符进行各种控制操作,取决于 cmd
参数的值。该函数的第三个参数 arg
的具体含义取决于 cmd
的值。
常见的 cmd
可选值:
F_DUPFD
: 复制文件描述符。arg
为新的文件描述符的最小允许值。F_GETFL
: 获取文件状态标志。arg
为无符号整数,表示文件的状态标志。F_SETFL
: 设置文件状态标志。arg
为要设置的状态标志的位掩码。F_GETLK
: 获取文件锁信息。arg
为指向struct flock
结构的指针,用于存储锁信息。F_SETLK
: 设置文件锁。arg
为指向struct flock
结构的指针,用于设置锁信息。F_SETLKW
: 设置文件锁,如果无法获取锁则阻塞。arg
为指向struct flock
结构的指针。
示例:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = open("example.txt", O_RDONLY);
if (fd == -1) {
perror("open");
return 1;
}
// 获取文件状态标志
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl");
close(fd);
return 1;
}
// 设置文件状态标志,添加 O_APPEND 标志
flags |= O_APPEND;
int result = fcntl(fd, F_SETFL, flags);
if (result == -1) {
perror("fcntl");
close(fd);
return 1;
}
// 其他操作...
close(fd);
return 0;
}
上述示例中,通过 fcntl
函数获取文件的状态标志,然后设置了 O_APPEND
标志,将文件设置为以追加方式打开。
后记
截至2024年1月20日11点21分,学习完《Linux高性能服务器编程》第六章的内容,主要介绍Linux的基础I/O函数。