4. 高级 I/O 函数
文章目录
- 4. 高级 I/O 函数
- 1.文件描述符
- 1.文件描述符的类型
- 2.文件描述符的使用
- 3.socket 和文件描述符的关系
- 4.Socket 与文件描述符的关系
- 2.pipe 函数
- 3.dup函数和dup2函数
- 3.readv函数和writev函数
- 4.sendfile函数
- 1.原先的read和write操作是如何进行的?
- 2.sendfile是如何进行的?
- 3.为啥对于write系统调用将数据写入文件 B,数据一般是从内核缓冲区复制到用户态缓冲区,然后再由用户态缓冲区写入文件 B 对应的内核缓冲区?不能直接从内核缓冲区复制到B对应的内核缓冲区吗?
- 5.mmap函数和munmap函数
- 6.splice函数
- 7.tee函数
- 8.fcntl函数
1.文件描述符
文件描述符(File Descriptor, FD)是操作系统中用于访问文件的一个抽象概念。它是一个非负整数,通常由操作系统分配,用来标识被打开的文件或输入输出资源(如管道、网络连接等)。文件描述符在操作系统和应用程序之间充当桥梁,允许程序通过文件描述符来读取、写入文件或进行其他I/O操作。
1.文件描述符的类型
文件描述符通常分为三类标准描述符:
- 标准输入(Standard Input,FD 0):
- 默认情况下与键盘关联,通常用于从用户那里接收输入数据。
- 标准输出(Standard Output,FD 1):
- 默认情况下与终端窗口关联,通常用于向用户显示输出数据。
- 标准错误(Standard Error,FD 2):
- 默认情况下也与终端窗口关联,但通常用于显示错误消息或诊断信息。
2.文件描述符的使用
在UNIX和类UNIX操作系统中,文件描述符用于各种I/O操作,包括:
- 打开文件:
open()
系统调用返回一个文件描述符,表示已打开的文件。 - 读取文件:
read()
系统调用使用文件描述符从文件中读取数据。 - 写入文件:
write()
系统调用使用文件描述符将数据写入文件。 - 关闭文件:
close()
系统调用使用文件描述符关闭文件,以释放系统资源。
文件描述符不仅限于文件,还可以用于网络套接字(socket)、管道(pipe)、设备文件等各种输入输出资源。通过文件描述符,程序可以对这些资源进行抽象的统一操作。
3.socket 和文件描述符的关系
socket 和文件描述符之间有着密切的关系,特别是在 UNIX 和类 [UNIX 操作系统](https://so.csdn.net/so/search?q=UNIX 操作系统&spm=1001.2101.3001.7020)中。简而言之,socket 是一种特殊类型的文件描述符,它用于网络通信。
4.Socket 与文件描述符的关系
- Socket 是文件描述符的一种:
- 在操作系统中,socket 被抽象为文件,这意味着每个 socket 都可以通过文件描述符进行标识和操作。文件描述符不仅用于文件,还可以用于其他 I/O 资源,如 socket、管道、设备文件等。
- Socket 的创建与文件描述符:
- 当你使用
socket()
系统调用创建一个 socket 时,操作系统会返回一个文件描述符,这个文件描述符代表了创建的 socket。例如:
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
这里,sockfd
就是一个文件描述符,后续的所有 socket 操作(如连接、发送、接收等)都将通过该文件描述符来进行。
- Socket 的操作类似于文件操作:
- 和普通文件一样,socket 的读写操作也是通过
read()
、write()
甚至是send()
、recv()
等系统调用来完成的。你可以使用这些调用函数来向 socket 发送或接收数据。例如:
char buffer[1024];
int n = read(sockfd, buffer, sizeof(buffer));
在这个例子中,read()
函数通过 sockfd
文件描述符从 socket 中读取数据。
- Socket 的关闭:
- 当 socket 不再需要使用时,可以使用
close()
系统调用关闭它,就像关闭文件一样。关闭操作将释放与该文件描述符相关的所有资源:
close(sockfd);
- 重定向与 socket:
- 在某些高级应用中,可以通过重定向文件描述符来实现 socket 与其他文件描述符的交换。例如,将标准输入/输出重定向到一个 socket,从而通过网络连接来读写数据。
2.pipe 函数
用于创建管道,实现进程之间的通信。
#include <unistd.h>//成功返回0,失败返回-1并设置errno
int pipe(int fd[2]);
fd[1]
只能用于数据写入。fd[0]
只能用于数据读出。
socket 的基础 API 中有一个 socketpair
函数:
#include <sys/types.h>
#include <sys/socket.h>
//成功返回0,失败返回-1设置errno
int socketpair(int domain, int type, int protocol, int fd[2]);
domain
只能使用UNIX本地协议族AF_UNIX
,所以socketpair
只能在本地使用,不过创建的这对文件描述符都是可读可写的。
3.dup函数和dup2函数
可以将标准输入重定向到一个文件,或者标准输出重定向到一个网络连接。
#include <unistd.h>
int dup(int file_descriptor);
int dup2(int file_descriptor_one, int file_descriptor_two);
以下程序使用dup函数实现了一个基本的CGI服务器:
#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 <libgen.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);// 监听套接字,最大等待连接队列的长度为5ret = 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 {// 先关闭标准输出文件描述符STDOUT_FILENO,其值为1close(STDOUT_FILENO);// 复制socket文件描述符connfd,由于dup函数总是返回系统中最小的可用文件描述符// 因此dup参数实际返回的是1,即之前关闭的标准输出文件描述符的值// 这样服务器输出到标准输出的内容会直接发送到与客户连接对应的socket上dup(connfd);printf("CGI服务器的简单实现\n");close(connfd);}close(sock);return 0;
}
代码首先关闭标准输出文件描述符STDOUT_FILENO
,其值为1(由宏定义);
dup(connfd);
复制客户端连接的文件描述符,并将其重定向为标准输出文件描述符1
(即STDOUT_FILENO
);
之后的printf("abcd\n");
语句输出的内容将通过套接字发送给客户端(而非终端)。
3.readv函数和writev函数
readv
函数将数据从文件描述符读到分散的内存块中,即分散读;writev
函数将多块分散的内存数据一并写入文件描述符中,即集中写:
#include <sys/uio.h>//成功返回读写的字节数,失败返回-1并设置errno
ssize_t readv(int fd, const struct iovec* vector, int count); //分散读
ssize_t writev(int fd, const struct iovec* vector, int count); //集中写
结构体 iovec
:
struct iovec{void *iov_base; //内存块起始地址size_t iov_len; //内存块长度
};
4.sendfile函数
sendfile
函数在两个文件描述符之间直接传递数据,完全在内核中操作,避免内核缓冲区和用户缓冲区之间的数据拷贝,效率高。这被称为零拷贝。
#include <sys/sendfile.h>//成功返回传输的字节数,失败返回-1并设置errno
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
之间传输的字节数。
in_fd必须是一个支持类似mmap函数的文件描述符,即它必须指向真实的文件,不能是socket和管道
out_fd必须是一个socket
1.原先的read和write操作是如何进行的?
用户调用read,write系统调用把A文件的内容读出写入B文件的过程
- 当用户调用
read
系统调用读取文件 A 时,操作系统通常会将文件 A 的内容先读取到内核缓冲区(不是用户态缓冲区)。这是因为磁盘 I/O 操作相对较慢,为了提高效率,内核会在内存中开辟一块缓冲区来缓存从磁盘读取的数据。 - 对于
write
系统调用将数据写入文件 B,数据一般是从内核缓冲区复制到用户态缓冲区,然后再由用户态缓冲区写入文件 B 对应的内核缓冲区,最后由内核将数据真正写入磁盘上的文件 B。
2.sendfile是如何进行的?
-
当使用
sendfile
函数将文件 A 的内容传输到文件 B 时,数据是直接在内核空间中进行转移的。具体来说,sendfile
系统调用会在内核中建立一个直接从文件 A 的内核缓冲区(磁盘缓存)到文件 B 的内核缓冲区的通道。 -
假设文件 A 存储在磁盘上,当发起
sendfile
操作时,内核首先会将文件 A 的数据读取到内核缓冲区(如果尚未缓存),这个过程与普通read
操作的磁盘读取部分类似。但是,与read
/write
组合不同的是,sendfile
不会将数据拷贝到用户空间。 -
然后,数据直接从文件 A 的内核缓冲区通过内核内部的机制被传输到文件 B 的内核缓冲区。这一步是
sendfile
函数高效的关键所在,它避免了数据从内核空间到用户空间,再从用户空间回到内核空间的两次拷贝过程。 -
最后,文件 B 的内核缓冲区中的数据会根据文件系统的要求和策略适时地被同步到磁盘上,完成文件 B 的更新。
3.为啥对于write系统调用将数据写入文件 B,数据一般是从内核缓冲区复制到用户态缓冲区,然后再由用户态缓冲区写入文件 B 对应的内核缓冲区?不能直接从内核缓冲区复制到B对应的内核缓冲区吗?
- 用户态和内核态的隔离原则
- 在操作系统中,用户态和内核态是严格隔离的。用户程序运行在用户态,而操作系统内核运行在内核态。这种隔离是为了系统的安全性和稳定性。内核负责管理硬件资源,如磁盘、内存等,用户程序如果可以随意访问内核缓冲区并直接写入文件对应的内核缓冲区,就可能会由于用户程序的错误(如非法内存访问、恶意篡改等)而破坏系统的稳定性和数据的完整性。
- 数据一致性和用户控制的需求
- 让数据先经过用户态缓冲区,用户程序可以对数据进行检查、修改等操作。例如,用户可能希望在写入文件之前对数据进行加密、添加特定的头部或尾部信息等操作。如果直接从内核缓冲区复制到文件 B 对应的内核缓冲区,用户程序就很难对中间的数据进行控制。(最直观易懂的理由)
- 从数据一致性的角度来看,用户态缓冲区也起到了一个临时存储和整理数据的作用。假设多个用户进程同时对一个文件进行写入操作,如果没有用户态缓冲区来协调这些写入操作,可能会导致数据混乱。用户态缓冲区可以确保数据按照用户程序期望的顺序和方式进行整理,然后再将正确的数据传递给内核进行最终的写入。
5.mmap函数和munmap函数
mmap
用于申请一段内存空间,这段内存可以用于进程间通信的共享内存,也可以直接将文件映射到其中,munmap
用于释放这段空间。
#include <sys/mman.h>//成功返回指向目标区域的指针,失败返回MAP_FAILED((void*) -1),并设置errno
void* mmap(void *start, size_t length, int port, int flags, int fd, off_t offset);
//成功返回0,失败返回-1并设置errno
int munmap(void *start, size_t length);
参数:
-
start
:允许用户使用某一个特定的地址作为这段内存的起始地址,如果是设置为NULL
,则系统自动分配。 -
length
:指定这段内存的长度 -
port
:设置内存段的访问权限。可以取下面这几个的按位或 -
PROT_READ
,内存段可读。PROT_WRITE
,内存段可写。PROT_EXEC
,内存段可执行。PROT_NONE
,内存段不能被访问。
-
flags
:控制内存段内容被修改后 程序的行为,可以取下面这几个的按位或 -
fd
:是被映射文件的文件描述符,一般通过open
系统调用获取 -
offset
:设置从文件的何处开始映射。
6.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
:是输入数据的文件描述符,用于数据的读出。如果是管道文件该参数必须为nullfd_out
和off_out
含义类似,用于输出数据流(数据写入)。len
:指定移动数据的长度。flags
:控制数据如何移动。
使用该函数的时候,fd_in和fd_out必须至少有一个是管道文件描述符
7.tee函数
tee
函数在两个管道文件描述符之间复制数据,也是零拷贝操作,不消耗数据,而splice
从管道中读取数据,也就是消耗数据。
#include <fcntl.h>
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);
参数:
fd_in
和fd_out
必须都是管道文件描述符- 其他的都和splice一样的
8.fcntl函数
fcntl
函数,正如其名字(file control)描述的那样,提供了对文件描述符的各种控制,另一个常见的控制文件描述符属性和行为的系统调用是ioctl
,且ioctl
函数比fcntl
函数能执行更多的控制,但控制文件描述符的常用属性和操作,fcntl
函数是由 POSIX 规范指定的首选方法:
#include <fcntl.h>//失败返回-1并设置errno
int fcntl(int fd, int cmd, ...);
参数:
fd
:被操作的文件描述符cmd
:指定执行何种类型的操作- 由于操作类型的不同,可能需要第三个可选参数
arg
fcntl
支持的常用操作:
在网络编程中,fcntl
函数常用于把文件描述符设置为非阻塞的:
int setnonblocking(int fd) {// 获取文件描述符状态标志int old_option = fcntl(fd, F_GETFL);// 设置非阻塞标志int new_option = old_option | O_NONBLOCK;fcntl(fd, F_SETFL, new_option);// 返回fd旧的状态标志,以便日后恢复该状态标志return old_option;
}