Linux文件IO基础

本章给大家介绍 Linux 应用编程中最基础的知识,即文件 I/O(Input、Outout),文件 I/O 指的是对文件的输入/输出操作,说白了就是对文件的读写操作;Linux 下一切皆文件,文件作为 Linux 系统设计思想的核心理念,在 Linux 系统下显得尤为重要,所以对文件的 I/O 操作既是基础也是最重要的部分。

一个通用的 IO 模型通常包括打开文件、读写文件、关闭文件这些基本操作,主要涉及到 4 个函数:open()、read()、write()以及 close(),在学习这些系统调用之前,我们先了解一些相关概念。

文件描述符

调用 open 函数会有一个返回值,该返回值经常被命名为"xxfdxx",这是一个 int 类型的数据,在open函数执行成功的情况下,会返回一个非负整数,该返回值就是一个文件描述符(file descriptor),这说明文件描述符是一个非负整数;对于 Linux 内核而言,所有打开的文件都会通过文件描述符进行索引。

当调用 open 函数打开一个现有文件或创建一个新文件时,内核会向进程返回一个文件描述符,用于指代被打开的文件,所有执行 IO 操作的系统调用都是通过文件描述符来索引到对应的文件,当调用 read/write 函数进行文件读写时,会将文件描述符传送给 read/write 函数。

一个进程可以打开多个文件,但是在 Linux 系统中,一个进程可以打开的文件数是有限制,并不是可以无限制打开很多的文件,大家想一想便可以知道,打开的文件是需要占用内存资源的,文件越大、打开的文件越多那占用的内存就越多,必然会对整个系统造成很大的影响,如果超过进程可打开的最大文件数限制,内核将会发送警告信号给对应的进程,然后结束进程;在 Linux 系统下,我们可以通过 ulimit 命令来查看进程可打开的最大文件数,用法如下所示:

ulimit -n

该最大值默认情况下是 1024,也就意味着一个进程最多可以打开 1024 个文件,当然这个限制数其实是可以设置的,这个就先不给大家介绍了,当然除了进程有最大文件数限制外,其实对于整个 Linux 系统来说,也有最大限制,那么关于这些问题,如果后面的内容中涉及到了再给大家进行介绍。

所以对于一个进程来说,文件描述符是一种有限资源,文件描述符是从 0 开始分配的,譬如说进程中第一个被打开的文件对应的文件描述符是 0、第二个文件是 1、第三个文件是 2、第 4 个文件是 3……以此类推,所以由此可知,文件描述符数字最大值为 1023(0~1023)。每一个被打开的文件在同一个进程中都有一个唯一的文件描述符,不会重复,如果文件被关闭后,它对应的文件描述符将会被释放,那么这个文件描述符将可以再次分配给其它打开的文件、与对应的文件绑定起来。

每次给打开的文件分配文件描述符都是从最小的没有被使用的文件描述符(0~1023)开始,当之前打开的文件被关闭之后,那么它对应的文件描述符会被释放,释放之后也就成为了一个没有被使用的文件描述符了。

当我们在程序中,调用 open 函数打开文件的时候,分配的文件描述符一般都是从 3 开始,这里大家可能要问了,上面不是说从 0 开始的吗,确实是如此,但是 0、1、2 这三个文件描述符已经默认被系统占用了,分别分配给了系统标准输入(0)、标准输出(1)以及标准错误(2),关于这个问题,这里不便给大家说太多,毕竟这是后面的内容,这里只是给大家提一下,后面遇到了再具体讲解。

Tips:Linux 系统下,一切皆文件,也包括各种硬件设备,使用 open 函数打开任何文件成功情况下便会返回对应的文件描述符 fd。每一个硬件设备都会对应于 Linux 系统下的某一个文件,把这类文件称为设备文件。所以设备文件对应的其实是某一硬件设备,应用程序通过对设备文件进行读写等操作、来使用、操控硬件设备,譬如 LCD 显示器、串口、音频、键盘等。

标准输入一般对应的是键盘,可以理解为 0 便是打开键盘对应的设备文件时所得到的文件描述符;标准输出一般指的是 LCD 显示器,可以理解为 1 便是打开 LCD 设备对应的设备文件时所得到的文件描述符;而标准错误一般指的也是 LCD 显示器。

ulimit -a命令用于显示当前shell会话中所有资源的限制信息。以下是一些常见的资源限制及其含义:

核心转储文件大小

参数-c

说明:该选项指定了内核转储文件(core dump file)的最大大小,以块(blocks)为单位,一个块通常是1024字节。默认情况下,这个值通常是0,表示不生成core文件。如果程序崩溃并且设置了合适的core文件大小限制,系统会生成core文件,用于调试程序错误。

数据段大小

参数-d

说明:此选项设置了进程数据段的最大值,通常用于限制程序通过brk()sbrk()系统调用可以分配的内存空间大小。默认情况下,数据段大小没有限制。对大多数应用程序而言,这个限制通常不需要特别设置,但对于一些需要大量内存分配的程序,可能需要调整此值。

文件描述符数

参数-n

说明:文件描述符是程序在执行过程中打开的文件、网络连接等资源的标识符。每个进程都有一个文件描述符表来管理这些资源。ulimit -n的值指定了进程可以同时打开的最大文件描述符数。在服务器环境中,如Web服务器或数据库服务器,可能需要同时处理大量的并发连接,因此需要增加这个限制以确保服务器能够正常处理请求。

调度优先级

参数-e

说明:该选项用于设置进程的调度优先级。较低的数值表示较高的优先级,即进程将获得更多的CPU时间片。普通用户的进程优先级范围通常是0到19,而超级用户(root)的进程优先级可以是负值,具有更高的优先级。

最大锁定内存地址空间

参数-l

说明:此选项指定了进程能够锁定的内存地址空间的最大数量,以字节(bytes)为单位。内存锁定可以防止内存被交换到磁盘上的swap空间,从而提高程序的性能,特别是对于需要频繁访问内存的程序,如数据库应用。但过度使用内存锁定可能会导致系统整体性能下降,因为可用的物理内存减少了。

内存使用最大值

参数-m

说明:该选项定义了进程能够使用的物理内存的最大量,以千字节(KB)为单位。合理设置内存使用上限可以防止单个进程占用过多的系统内存,导致其他进程因内存不足而无法正常运行。

打开文件的最大值

参数-u

说明:这个选项规定了用户最多能打开的文件描述符数。与-n选项类似,但它更侧重于对用户级别的限制,而不是单个进程。在一些多用户共享的系统中,限制每个用户能够打开的文件数量可以防止某个用户独占过多的文件资源,影响其他用户的正常使用。

总的来说,ulimit -a命令提供了一种查看和了解当前shell会话中各种资源限制的方式,包括核心转储文件大小、数据段大小、文件描述符数、调度优先级、最大锁定内存地址空间、内存使用最大值以及打开文件的最大值等。

open打开文件

在 Linux 系统中要操作一个文件,需要先打开该文件,得到文件描述符,然后再对文件进行相应的读写操作(或其他操作),最后在关闭该文件;open 函数用于打开文件,当然除了打开已经存在的文件之外,还可以创建一个新的文件,函数原型如下所示:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

在应用程序中调用 open 函数即可传入 2 个参数(pathname、flags)、也可传入 3 个参数(pathname、flags、mode),但是第三个参数 mode 需要在第二个参数 flags 满足条件时才会有效,稍后将对此进行说明。

在应用程序中使用 open 函数时,需要包含 3 个头文件“#include <sys/types.h>”、“#include <sys/stat.h>”、“#include <fcntl.h>”。

函数参数和返回值含义如下:

pathname:字符串类型,用于标识需要打开或创建的文件,可以包含路径(绝对路径或相对路径)信息,譬如:"./src_file"(当前目录下的 src_file 文件)、"/home/dengtao/hello.c"等;如果 pathname 是一个符号链接,会对其进行解引用。

flags:调用 open 函数时需要提供的标志,包括文件访问模式标志以及其它文件相关标志,这些标志使用宏定义进行描述,都是常量,open 函数提供了非常多的标志,我们传入 flags 参数时既可以单独使用某一个标志,也可以通过位或运算(|)将多个标志进行组合。这些标志介绍如下:

  • O_RDONLY :以只读方式打开文件
  • O_WRONLY :以只写方式打开文件
  • O_RDWR :以可读可写方式打开文件
  • 以上三个是文件访问权限标志,传入的flags 参数中必须要包含其中一种标志,而且只能包含一种,打开的文件只能按照这种权限来操作,譬如使用了 O_RDONLY 标志,就只能对文件进行读取操作,不能写操作。
  • O_CREAT:如果 pathname 参数指向的文件不存在则创建此文件
  • 使用此标志时,调用 open 函数需要传入第 3 个参数 mode,参数 mode 用于指定新建文件的访问权限。open 函数的第 3 个参数只有在使用了 O_CREAT 或 O_TMPFILE 标志时才有效。
  • O_DIRECTORY :如果 pathname 参数指向的不是一个目录,则调用 open 失败
  • O_EXCL :此标志一般结合 O_CREAT 标志一起使用,用于专门创建文件。
  • 在 flags 参数同时使用到了 O_CREAT 和O_EXCL 标志的情况下,如果 pathname 参数指向的文件已经存在,则 open 函数返回错误。可以用于测试一个文件是否存在,如果不存在则创建此文件,如果存在则返回错误,这使得测试和创建两者成为一个原子操作。
  • O_NOFOLLOW :如果 pathname 参数指向的是一个符号链接,将不对其进行解引用,直接返回错误。
  • O_TRUNC :调用 open 函数打开文件的时候会将文件原本的内容全部丢弃,文件大小变为 0;
  • O_APPEND :调用 open 函数打开文件,当每次使用 write()函数对文件进行写操作时,都会自动把文件当前位置偏移量移动到文件末尾, 从文件末尾开始写入数据,也就是意味着每次写入数据都是从文件末尾开始。
  • O_NONBLOCK:以非阻塞的方式打开文件。常用于设备文件,以避免在设备未就绪时阻塞。

  • O_SYNC:影响write函数的行为。当设置该标志时,write函数会阻塞等待底层完成硬盘写入才返回,以确保数据立即被写入硬盘。

小细节:O_APPEND标志并不会影响读文件,当读取文件时, O_APPEND 标志并不会影响读位置偏移量, 即使使用了 O_APPEND标志,读文件位置偏移量默认情况下依然是文件头,使用 lseek 函数来改变 write()时的写位置偏移量也不会成功,当执行 write()函数时,检测到 open 函数携带了 O_APPEND 标志,所以在 write 函数内部会自动将写位置偏移量移动到文件末尾。

Tips:不同内核版本所支持的 flags 标志是存在差别的,譬如说新版本内核所支持的标志可能在老版本是不支持的,亦或者老版本支持的标志在新版本已经被取消、替代,man 手册中对一些标志是从哪个版本开始支持的有简单地说明,读者可以自行阅读!

前面我们说过,flags 参数时既可以单独使用某一个标志,也可以通过位或运算(|)将多个标志进行组合,譬如:

open("./src_file", O_RDONLY)//单独使用某一个标志

open("./src_file", O_RDONLY | O_NOFOLLOW)//多个标志组合

mode:此参数用于指定新建文件的访问权限,只有当 flags 参数中包含 O_CREAT 或 O_TMPFILE 标志时才有效(O_TMPFILE 标志用于创建一个临时文件)。

open 函数使用示例

(1)使用 open 函数打开一个已经存在的文件(例如当前目录下的 app.c 文件),使用只读方式打开:

int fd = open("./app.c", O_RDONLY)
if (-1 == fd)
return fd;

(2)使用 open 函数打开一个已经存在的文件(例如当前目录下的 app.c 文件),使用可读可写方式打开:

int fd = open("./app.c", O_RDWR)
if (-1 == fd)
return fd;

关于open函数的第三个参数mode_t mode

Linux 系统中,文件的基本权限由 9 个字符组成,以 rwxrw-r-x 为例,我们可以使用数字来代表各个权限,各个权限与数字的对应关系如下:

r --> 4 
w --> 2 
x --> 1

由于这 9 个字符分属 3 类用户,因此每种用户身份包含 3 个权限(r、w、x),通过将 3 个权限对应的数字累加,最终得到的值即可作为每种用户所具有的权限。 拿 rwxrw-r-x 来说,所有者、所属组和其他人分别对应的权限值为:

所有者 = rwx = 4+2+1 = 7 所属组 = rw- = 4+2 = 6 其他人 = r-x = 4+1 = 5

所以,此权限对应的权限值就是 765。

这其实是一种8进制的思维。

注意,在linux中执行chmod的时候,系统可以按照8进制解析;但是如果在c语言里直接显示765,那么就会被当做十进制的765,所以在c语言里,需要显式地表明为8进制数据。

当我们使用chmod命令时,可以直接输入644。这是因为chmod命令将输入的644(可能以字符串形式接收)视为8进制数,然后转换为相应的10进制数,最后通过系统调用传递给chmod函数。

这也就是为什么open函数在指定权限时,需要写成0765的形式,而不是直接写成765,其实就是显式地指定为8进制.不要和linux里的命令搞混了。

虽然mode_t是一个unsigned int类型,但当我们使用chmod函数时,mode参数并不是简单地作为一个整数来处理。它实际上是以8进制(octal)的形式来解析的。

这里有一些常用的mode参数选项:

S_ISUID                                    04000  // Set user-id on execution
S_ISGID                                    02000  // Set group-id on execution
S_ISVTX                                    01000  // Sticky bit
S_IRUSR(S_IREAD)      00400           //文件所有者具可读取权限
S_IWUSR(S_IWRITE)     00200           //文件所有者具可写入权限 
S_IXUSR(S_IEXEC)      00100           //文件所有者具可执行权限
S_IRGRP                 00040           //用户组具可读取权限
S_IWGRP                                    00020           //用户组具可写入权限
S_IXGRP                                    00010           //用户组具可执行权限
S_IROTH                                    00004           //其他用户具可读取权限
S_IWOTH                                    00002            //其他用户具可写入权限
S_IXOTH                                    00001            //他用户具可执行权限

假设我们要将文件test的权限设置为644,有以下几种方式:

fd=open("test", O_RDWR | O_CREAT | O_EXCL, 0644); // Method 1

fd=open("test", O_RDWR | O_CREAT | O_EXCL, S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH); // Method 2

fd=open("test", O_RDWR | O_CREAT | O_EXCL, 420); // Method 3

这三种方法实际上是等效的。第一种方法是通过位运算将各个权限合并在一起,最终得到的是8进制的0644,这等于10进制的420

write写文件

调用 write 函数可向打开的文件写入数据,其函数原型如下所示(可通过"man 2 write"查看):

#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);

首先使用 write 函数需要先包含 unistd.h 头文件。

函数参数和返回值含义如下:

fd:文件描述符。关于文件描述符,前面已经给大家进行了简单地讲解,这里不再重述!我们需要将进行写操作的文件所对应的文件描述符传递给 write 函数。

buf:指定写入数据对应的缓冲区。

count:指定写入的字节数。

返回值:如果成功将返回写入的字节数(0 表示未写入任何字节),如果此数字小于 count 参数,这不是错误,譬如磁盘空间已满,可能会发生这种情况;如果写入出错,则返回-1。

对于普通文件(我们一般操作的大部分文件都是普通文件,譬如常见的文本文件、二进制文件等),不管是读操作还是写操作,一个很重要的问题是:从文件的哪个位置开始进行读写操作?也就是 IO 操作所对应的位置偏移量,读写操作都是从文件的当前位置偏移量处开始,当然当前位置偏移量可以通过 lseek 系统调用进行设置,关于此函数后面再讲;默认情况下当前位置偏移量一般是 0,也就是指向了文件起始位置,当调用 read、write 函数读写操作完成之后,当前位置偏移量也会向后移动对应字节数,譬如当前位置偏移量为 1000 个字节处,调用 write()写入或 read()读取 500 个字节之后,当前位置偏移量将会移动到 1500 个字节处。

发送缓存(Transmit Buffer)

当你准备将数据发送到网络上时,数据通常会先被写入发送缓存。发送缓存是一个临时存储区域,用于保存即将通过网络接口发送的数据包。

例如,在TCP/IP协议栈中,当应用程序调用send()函数时,数据会被复制到发送缓存中,然后由操作系统的网络栈负责将其发送出去。

接收缓存(Receive Buffer)

当你从网络上接收数据时,数据会首先被写入接收缓存。接收缓存是一个临时存储区域,用于保存从网络接口接收到的数据包。

例如,在TCP/IP协议栈中,当数据包到达网络接口时,它们会被放入接收缓存中,然后由操作系统的网络栈处理并传递给相应的应用程序。

read读文件

调用 read 函数可从打开的文件中读取数据,其函数原型如下所示(可通过"man 2 read"查看):

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);

首先使用 read 函数需要先包含 unistd.h 头文件。

函数参数和返回值含义如下:

fd:文件描述符。与 write 函数的 fd 参数意义相同。

buf:指定用于存储读取数据的缓冲区。

count:指定需要读取的字节数。

返回值:如果读取成功将返回读取到的字节数,实际读取到的字节数可能会小于 count 参数指定的字节数,也有可能会为 0,譬如进行读操作时,当前文件位置偏移量已经到了文件末尾。实际读取到的字节数少于要求读取的字节数,譬如在到达文件末尾之前有 30 个字节数据,而要求读取 100 个字节,则 read 读取成功只能返回 30;而下一次再调用 read 读,它将返回 0(文件末尾)。

readwrite 函数在处理文件描述符时的行为取决于文件描述符的类型以及它们所关联的设备或文件的特性。

阻塞行为

read 函数:当从普通文件、管道或终端读取数据时,如果没有足够的数据可供读取,read 调用会阻塞,直到有数据可读或者发生错误。

write 函数:当向普通文件、管道或终端写入数据时,如果缓冲区已满,write 调用也会阻塞,直到有足够的空间可以写入数据或者发生错误。

非阻塞行为

如果文件描述符被设置为非阻塞模式(通过使用 fcntl 系统调用设置 O_NONBLOCK 标志),那么 readwrite 调用将不会阻塞。相反,如果没有数据可读或没有空间可写,这些调用会立即返回一个错误(通常是 EAGAINEWOULDBLOCK)。

当应用程序进行系统调用比如read函数时,read函数可能会阻塞,此时,应用程序会被挂起,于是我有个疑惑:驱动程序会被阻塞吗?

Linux驱动程序会阻塞住,以下是一些可能导致阻塞的情况:

  1. 资源等待

    1. I/O资源:当驱动程序试图访问某些I/O设备,但这些设备暂时不可用时,比如设备忙、设备未准备好等,驱动程序可能会进入阻塞状态,等待设备变得可用。例如,试图从正在处理其他任务的打印机驱动程序中打印文件,如果打印机的输出端口被占用,那么打印进程会被阻塞,直到打印机准备好接收新的打印任务。

    2. 内存资源:如果驱动程序需要分配大量的内存,但系统内存不足,驱动程序可能会阻塞,直到有足够的内存可供分配。这种情况在一些需要大量缓冲区或数据结构的驱动程序中较为常见,比如视频驱动需要为图像处理分配较大的内存空间。

  2. 同步机制

    1. 信号量等待:在多线程或中断上下文中,驱动程序可能会使用信号量来实现对共享资源的互斥访问。如果一个线程试图获取一个已经被其他线程持有的信号量,该线程将被阻塞,直到信号量被释放。例如,多个线程同时访问一个硬件设备的寄存器,就需要通过信号量来保证同一时间只有一个线程可以进行访问。

    2. 管程等待:管程是一种更高级的同步机制,它允许多个线程在某些条件下进行协作。驱动程序在使用管程时,可能会因为不满足条件而阻塞,等待其他线程或中断处理程序改变条件。

  3. 数据传输等待

    1. 网络数据传输:对于网络设备驱动程序,当发送数据包时,如果网络接口繁忙或者网络拥塞,驱动程序可能会阻塞,等待网络接口能够接收数据包进行发送。同样,在接收数据包时,如果没有足够的缓冲区空间来存储接收到的数据包,也会阻塞,直到有空间为止。

    2. 磁盘数据传输:磁盘驱动程序在读写磁盘扇区时,如果磁盘控制器忙于其他操作或者磁盘本身出现故障,可能会导致驱动程序阻塞,等待磁盘准备好进行数据传输。

  4. 用户空间交互等待

    1. 用户态应用程序请求:驱动程序可能需要等待用户态应用程序的请求或输入。例如,一个字符设备驱动程序在没有收到用户的读写请求时,可能会阻塞在等待队列中,直到有用户进程调用相应的读写操作。

    2. 用户空间数据处理:有些驱动程序需要在设备和用户空间之间传输数据,并等待用户空间程序对数据的处理结果。如果用户空间程序处理数据的速度较慢,驱动程序可能会被阻塞,直到用户空间程序返回处理结果。

综上所述,Linux驱动程序确实存在多种可能导致阻塞的情况。这些阻塞情况通常与资源等待、同步机制、数据传输等待以及用户空间交互等待等因素有关。在实际应用中,驱动程序开发者需要充分考虑这些因素,合理设计驱动程序的逻辑和流程,以避免不必要的阻塞和性能下降。

阻塞时进程就无法继续向后执行了,会阻塞等待直到条件满足,非阻塞时,如果不满足条件就会马上返回或者超时返回,这时,我们后续可以通过判断对这种情况进行处理,比如直接终止程序,又或者去做别的事情,而阻塞就不由用户来控制了,而是被操作系统挂起,用户无法进行后续处理。

Linux应用程序阻塞和驱动程序阻塞有什么区别?

Linux应用程序阻塞和驱动程序阻塞在多个方面存在显著的区别,以下是具体的对比分析:

一、定义与概念层面

应用程序阻塞:指应用程序在执行过程中,由于某些条件未满足而导致程序的执行流程暂停,需等待特定条件成立后才能继续执行。这些条件多与I/O操作相关,如等待用户输入、文件读取、网络数据接收等。例如,一个网络聊天应用程序,当发送消息时如果网络接口繁忙无法立即发送,该应用程序就会阻塞在发送操作上,直到网络接口有空闲资源可进行数据传输。

驱动程序阻塞:主要发生在内核空间的设备驱动程序中,当驱动程序请求硬件设备进行操作,但硬件设备暂时无法响应时,驱动程序会进入阻塞状态。比如,当应用程序通过驱动程序向打印机发送打印任务,而打印机正在处理其他任务或处于故障状态,此时驱动程序会阻塞,等待打印机准备好接收新任务。

二、阻塞原因层面

应用程序阻塞

  1.   I/O等待:如上述例子中的网络传输、文件读写等I/O操作,若相关设备或资源忙碌,应用程序会阻塞等待。

  1.   系统调用:一些系统调用可能会使应用程序阻塞,例如进程间通信的同步操作,当等待其他进程的信号或共享资源时,应用程序会阻塞。

  1.   用户输入:对于需要用户交互的程序,如命令行工具,当等待用户输入命令或数据时,应用程序会阻塞在输入操作上。

驱动程序阻塞

  1.   硬件资源限制:硬件设备的缓冲区已满、带宽不足、设备忙等情况都会导致驱动程序阻塞。例如,网络适配器的发送队列已满,驱动程序在尝试发送数据时就会阻塞,直到队列有空间。

  1.   硬件故障:硬件设备出现故障或异常状态时,驱动程序可能无法正常完成操作而阻塞。比如硬盘出现坏道,驱动程序在读写到坏道区域时会尝试修复或等待硬件恢复正常,导致阻塞。

  1.   硬件访问冲突:多个驱动程序同时访问同一硬件资源时,可能会出现冲突而导致阻塞。例如,两个驱动程序同时尝试控制同一个I/O端口,就需要通过锁机制来解决冲突,在获取锁之前,驱动程序可能会阻塞。

三、对系统影响层面

应用程序阻塞

  1.   用户体验:应用程序阻塞会导致界面无响应,严重影响用户体验。例如,一个图形界面的应用程序在阻塞时,窗口可能会变得灰色或无法点击,让用户以为程序崩溃。

  1.   系统资源利用:阻塞的应用程序会占用一定的系统资源,如CPU时间片、内存等,但无法有效地进行工作,从而降低系统的整体性能和资源利用率。

  1.   程序执行效率:应用程序阻塞会使整个程序的执行流程暂停,增加任务完成的时间,降低程序的执行效率。

驱动程序阻塞

  1.   系统稳定性:驱动程序阻塞可能会导致系统不稳定,因为驱动程序是操作系统与硬件设备之间的桥梁,其阻塞可能会影响整个系统的正常运行。例如,磁盘驱动程序阻塞可能会导致文件系统无法正常访问,进而影响系统中所有依赖该文件系统的程序。

  1.   硬件性能发挥:驱动程序阻塞会影响硬件设备的性能发挥,无法充分利用硬件的资源。比如,网卡驱动程序阻塞会导致网络带宽无法得到有效利用,降低网络传输速度。

  1.   系统响应时间:由于驱动程序阻塞可能导致应用程序的I/O操作延迟,从而增加系统的响应时间。例如,用户打开一个文件,如果文件系统驱动程序阻塞,用户可能需要等待较长时间才能看到文件内容。

四、处理方式层面

应用程序阻塞

  1.   多线程或多进程:通过创建新的线程或进程来避免单个操作阻塞整个应用程序。例如,下载管理器可以使用多线程同时下载多个文件,即使某个文件下载缓慢,也不会影响其他文件的下载。

  1.   异步编程模型:使用异步I/O操作,让应用程序在等待I/O操作完成的同时可以继续执行其他任务。例如,使用回调函数或Promise来处理网络请求,当网络请求完成后再通知应用程序进行处理。

  1.   超时机制:设置操作的超时时间,当超过超时时间后放弃操作或采取其他措施。例如,网络连接超时后可以自动重试或提示用户网络连接失败。

驱动程序阻塞

  1.   中断处理:硬件设备通常会通过中断信号通知驱动程序操作已完成或可以进行下一步操作。驱动程序需要在中断处理程序中及时响应中断,以减少阻塞时间。例如,网络适配器在收到数据包后会产生中断,驱动程序在中断处理程序中将数据包从硬件缓冲区复制到内存中,然后通知应用程序有数据可读。

  1.   轮询机制:在一些情况下,驱动程序可以通过轮询的方式不断检查硬件设备的状态,以确定是否可以进行下一步操作。不过,轮询会消耗较多的CPU资源。

  1.   错误处理与恢复:驱动程序需要能够正确处理硬件错误,并尝试进行恢复操作。例如,当打印机出现卡纸错误时,驱动程序可以尝试清除卡纸并重新启动打印任务。

综上所述,Linux应用程序阻塞和驱动程序阻塞在定义、阻塞原因、对系统的影响以及处理方式等方面均存在明显区别。了解这些区别有助于开发人员更好地理解和优化Linux系统中的应用程序和驱动程序性能。

Linux内核和驱动里也有多线程吗

Linux内核和驱动中都有多线程机制,它们在系统运行和设备管理中发挥着重要作用,以下是具体介绍:

  1. Linux内核中的多线程

    1. 内核线程的概念:内核线程是直接由内核本身启动的进程,用于处理特定的任务。它与用户态进程不同,没有独立的地址空间,mm 指针被设置为 NULL,只在内核空间运行,从来不切换到用户空间去,并且和普通进程一样,可以被调度,也可以被抢占。内核线程可以看作是内核的分身,一个分身可以处理一件特定事情。

    2. 内核线程的创建:创建内核线程最基本的两个接口函数是 kthread_runkernel_thread。其中 kthread_run 是一个宏定义,它结合了 kthread_createwake_up_process 的功能,使得用其创建的线程可以直接运行。例如,使用 kthread_run(threadfn, data, namefmt, ...) 可以创建一个内核线程并立即唤醒它。

    3. 内核线程的调度与管理:内核线程的调度由内核负责,一个内核线程处于阻塞状态时不影响其他的内核线程,因为其是调度的基本单位。内核中有专门的线程 kthreadd,它会不断检查 kthread_create_list 链表,当发现有创建内核线程的请求时,会调用 create_kthread 创建相应的线程。

  2. Linux驱动中的多线程

    1. 多线程的必要性:在 Linux 设备驱动中,引入多线程机制可以提高驱动程序的性能和响应能力。例如,当设备需要进行复杂的操作或处理大量的数据时,单线程可能无法满足实时性要求,而多线程可以让不同的任务并行处理,从而提高整体效率。

    2. 互斥与同步机制:由于多个线程可能会同时访问共享资源,因此需要使用互斥与同步机制来避免竞态条件和数据不一致的问题。常见的互斥与同步机制包括自旋锁、信号量、互斥锁等。例如,自旋锁适用于短时间的临界区保护,信号量可以用于控制对共享资源的访问次数,互斥锁则可以提供更严格的互斥访问控制。

    3. 多线程编程注意事项:在编写多线程驱动时,需要注意线程的安全性和稳定性。例如,要确保每个线程都有正确的入口函数和退出机制,避免出现线程泄漏或崩溃的情况。同时,还需要注意线程之间的优先级设置和调度策略,以确保重要的任务能够及时得到处理。

综上所述,Linux内核和驱动中的多线程机制为系统的高效运行和设备的灵活管理提供了有力支持,但也需要开发者在使用时充分考虑线程的创建、调度、同步等问题,以确保系统的稳定性和可靠性。

Linux驱动程序里如果需要创建线程,可以使用pthread_create函数吗

在Linux驱动程序中,一般不使用 pthread_create 函数来创建线程。主要原因如下:

  1. 内核态与用户态的差异

    1. 运行环境不同:Linux内核运行在内核态,而 pthread_create 函数属于用户态的线程库函数,用于在用户空间创建线程。内核态的代码和用户态的代码有不同的权限、资源访问限制以及运行机制等,直接在内核中使用 pthread_create 会导致运行环境不匹配,可能引发系统错误或不稳定。

    2. 调度和管理方式不同:内核有自己的进程间通信(IPC)机制、内存管理机制和调度算法,以确保系统的高效运行和安全性。而用户态的线程库函数依赖于用户空间的库和系统调用来实现线程的创建和管理,与内核的管理方式不兼容。

  2. 内核提供的线程创建机制

    1. 内核线程创建函数:Linux内核提供了专门的函数来创建内核线程,如 kernel_threadkthread_create 等。这些函数是专门为内核环境设计的,能够正确地在内核态创建线程,并按照内核的调度规则进行管理。例如,使用 kernel_thread 可以指定线程函数、参数以及线程属性等,创建的内核线程会与内核的其他部分紧密集成,能够方便地进行资源分配和调度。

    2. 适应内核需求:内核中的线程通常需要与硬件设备、文件系统、网络协议栈等紧密交互,需要遵循内核的编程规范和接口标准。使用内核提供的线程创建函数可以确保线程能够正确地与这些内核组件进行协作,而 pthread_create 无法满足这些特定的需求。

  3. 兼容性和可移植性问题

    1. 内核代码的特殊性:Linux内核代码需要在不同的硬件平台和架构上运行,需要保证高度的兼容性和可移植性。使用内核特定的线程创建函数可以确保在不同平台上的一致性和正确性,而 pthread_create 可能在某些特殊平台或架构上存在兼容性问题。

    2. 与内核其他部分的集成:内核是一个复杂的软件系统,各个部分之间相互依赖、协同工作。使用内核提供的线程创建机制可以更好地与其他内核组件进行集成,方便进行代码的维护和升级。如果使用 pthread_create,可能会破坏这种内在的集成关系,给内核的开发和维护带来困难。

综上所述,在Linux驱动程序中,应使用内核提供的特定函数来创建线程,而不是 pthread_create 函数,以确保代码的正确性、稳定性和与内核其他部分的良好集成。

Linux内核线程和用户线程是统一调度还是分开调度?

Linux内核线程和用户线程是分开调度的。以下是对这两种线程调度方式的具体说明:

  1. 内核线程调度

    1. 调度者:由操作系统内核负责调度。

    2. 调度对象:内核线程本身作为调度的基本单位。

    3. 特点:内核线程运行在内核空间,拥有更高的权限和优先级,主要用于执行操作系统的核心功能,如进程调度、文件系统管理、设备驱动等。一个内核线程处于阻塞状态时不影响其他的内核线程。

  2. 用户线程调度

    1. 调度者:一般由用户程序或线程库(如POSIX Pthreads)负责调度,而非直接由操作系统内核调度。

    2. 调度对象:用户线程所在的进程(用户空间)。

    3. 特点:用户线程存在于用户空间,依赖于用户态的线程库实现上下文切换,速度相对较慢。如果一个用户线程阻塞在系统调用上,可能导致整个进程阻塞。在多处理器环境下,用户线程可能无法充分利用多核CPU资源。

总的来说,Linux内核线程和用户线程在调度机制上存在明显差异,内核线程由操作系统内核统一调度,具有高权限和优先级;而用户线程则更多地依赖于用户程序或线程库进行调度,其调度灵活性较高但性能相对较低。

更多参考:Linux内核线程与用户线程 - 知乎 (zhihu.com)

暂时不深究了。

close关闭文件

可调用 close 函数关闭一个已经打开的文件,其函数原型如下所示(可通过"man 2 close"查看):

#include <unistd.h>
int close(int fd);

首先使用 close 函数需要先包含 unistd.h 头文件,当我们对文件进行 IO 操作完成之后,后续不再对文件进行操作时,需要将文件关闭。

函数参数和返回值含义如下:

fd:文件描述符,需要关闭的文件所对应的文件描述符。

返回值:如果成功返回 0,如果失败则返回-1。

除了使用 close 函数显式关闭文件之外,在 Linux 系统中,当一个进程终止时,内核会自动关闭它打开的所有文件,也就是说在我们的程序中打开了文件,如果程序终止退出时没有关闭打开的文件,那么内核会自动将程序中打开的文件关闭。很多程序都利用了这一功能而不显式地用 close 关闭打开的文件。

显式关闭不再需要的文件描述符往往是良好的编程习惯,会使代码在后续修改时更具有可读性,也更可靠,进而言之,文件描述符是有限资源,当不再需要时必须将其释放、归还于系统。

lseek

对于每个打开的文件,系统都会记录它的读写位置偏移量,我们也把这个读写位置偏移量称为读写偏移量,记录了文件当前的读写位置,当调用 read()或 write()函数对文件进行读写操作时,就会从当前读写位置偏移量开始进行数据读写。

读写偏移量用于指示 read()或 write()函数操作时文件的起始位置,会以相对于文件头部的位置偏移量来表示,文件第一个字节数据的位置偏移量为 0。

当打开文件时,会将读写偏移量设置为指向文件开始位置处,以后每次调用 read()、write()将自动对其进行调整,以指向已读或已写数据后的下一字节,因此,连续的调用 read()和 write()函数将使得读写按顺序递增,对文件进行操作。我们先来看看 lseek 函数的原型,如下所示(可通过"man 2 lseek"查看):

#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);

首先调用 lseek 函数需要包含<sys/types.h>和<unistd.h>两个头文件。

函数参数和返回值含义如下:

fd:文件描述符。

offset:偏移量,以字节为单位。

whence:用于定义参数 offset 偏移量对应的参考值,该参数为下列其中一种(宏定义):

⚫ SEEK_SET:读写偏移量将指向 offset 字节位置处(从文件头部开始算);

⚫ SEEK_CUR:读写偏移量将指向当前位置偏移量 + offset 字节位置处,offset 可以为正、也可以为负,如果是正数表示往后偏移,如果是负数则表示往前偏移;

⚫ SEEK_END:读写偏移量将指向文件末尾 + offset 字节位置处,同样 offset 可以为正、也可以为负,如果是正数表示往后偏移、如果是负数则表示往前偏移。

返回值:成功将返回从文件头部开始算起的位置偏移量(字节为单位),也就是当前的读写位置;发生错误将返回-1。

使用示例:

(1)将读写位置移动到文件开头处:
off_t off = lseek(fd, 0, SEEK_SET);
if (-1 == off)
return -1;

(2)将读写位置移动到文件末尾:
off_t off = lseek(fd, 0, SEEK_END);
if (-1 == off)
return -1;

(3)将读写位置移动到偏移文件开头 100 个字节处:
off_t off = lseek(fd, 100, SEEK_SET);
if (-1 == off)
return -1;

(4)获取当前读写位置偏移量:
off_t off = lseek(fd, 0, SEEK_CUR);
if (-1 == off)
return -1;
函数执行成功将返回文件当前读写位置。

fsync

fsync是一个Linux系统调用。以下是对fsync函数的详细解释:

函数原型

int fsync(int fd);

参数fd是要同步的文件描述符。

返回值:成功时返回0,失败时返回-1并设置errno以指明错误。

基本概念:fsync函数用于将文件系统中的数据和元数据从内存缓冲区刷新到磁盘上,以确保数据的持久性和可靠性。在调用fsync之前,数据可能仅存储在内存中的缓存中,并没有写入磁盘,因此在某些情况下,数据的持久性可能无法得到保证。

工作原理:当应用程序调用fsync时,操作系统将会强制将内存中的数据以及文件的元数据信息(例如inode)写入磁盘。这样,即使在发生系统故障或意外断电等情况下,数据也能够被恢复。fsync通过在缓冲数据块对应的磁盘块上更新文件系统的元数据来实现其功能。

应用场景:fsync广泛应用于需要确保数据安全性和可靠性的场景,例如日志记录、文件存储和数据库系统中。特别是在数据库应用中,fsync函数常被用来确保关键交易数据的持久性,以防止因系统崩溃或断电导致的数据丢失。

与其他函数的区别:与fsync类似,但fdatasync只影响文件数据部分,强制传送用户已写出的数据至物理存储设备,不包括文件本身的特征数据。sync函数则负责将系统缓冲区的数据“写入”磁盘,以确保数据的一致性和同步性。但sync函数只是将所有修改过的块缓冲区排入写队列,然后就返回,它并不等待实际I/O操作结束。

注意事项

fsync是一个阻塞操作,会占用文件描述符,直到同步操作完成。

调用fsync函数可能会导致性能下降,因为它需要等待磁盘I/O操作完成。

综上所述,fsync函数是Linux系统中一个非常重要的函数,它可以确保文件系统中的数据和元数据的一致性和持久性。在实际应用中,开发者需要根据具体需求权衡使用fsync函数的频率和性能开销。 

Tips:再给大家说一点,就是关于函数返回值的问题,我们可以发现,本章给大家所介绍的这些函数都是有返回值的,其实不管是 Linux 应用编程 API 函数,还是驱动开发中所使用到的函数,基本上都是有返回值的,返回值的作用就是告诉开发人员此函数执行的一个状态是怎样的,执行成功了还是失败了,在Linux 系统下,绝大部分的函数都是返回 0 作为函数调用成功的标识、而返回负数(譬如-1)表示函数调用失败,如果大家学习过驱动开发,想必对此并不陌生,所以很多时候可以使用如下的方式来判断函数执行成功还是失败:

if (func()) {
    //执行失败
} else {
    //执行成功
}

当然以上说的是大部分情况,并不是所有函数都是这样设计,所以呢,这里笔者也给大家一个建议,自己在进行编程开发的时候,自定义函数也可以使用这样的一种方法来设计你的函数返回值,不管是裸机程序亦或是 Linux 应用程序、驱动程序。

Linux 系统如何管理文件

静态文件与 inode

文件在没有被打开的情况下一般都是存放在磁盘中的,譬如电脑硬盘、移动硬盘、U 盘等外部存储设备,文件存放在磁盘文件系统中,并且以一种固定的形式进行存放,我们把他们称为静态文件。文件储存在硬盘上,硬盘的最小存储单位叫做“扇区”(Sector),每个扇区储存 512 字节(相当于 0.5KB),操作系统读取硬盘的时候,不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个“块”(block)。这种由多个扇区组成的“块”,是文件存取的最小单位。“块”的大小,最常见的是 4KB,即连续八个 sector 组成一个 block。

所以由此可以知道,静态文件对应的数据都是存储在磁盘设备不同的“块”中,那么问题来了,我们在程序中调用 open 函数是如何找到对应文件的数据存储“块”的呢,难道仅仅通过指定的文件路径就可以实现?这里我们就来简单地聊一聊这内部实现的过程。

我们的磁盘在进行分区、格式化的时候会将其分为两个区域,一个是数据区,用于存储文件中的数据;另一个是 inode 区,用于存放 inode table(inode 表),inode table 中存放的是一个一个的 inode(也成为 inode节点),不同的 inode 就可以表示不同的文件,每一个文件都必须对应一个 inode,inode 实质上是一个结构体,这个结构体中有很多的元素,不同的元素记录了文件了不同信息,譬如文件字节大小、文件所有者、文件对应的读/写/执行权限、文件时间戳(创建时间、更新时间等)、文件类型、文件数据存储的 block(块)位置等等信息,如图3.1.1 中所示(这里需要注意的是,文件名并不是记录在 inode 中,这个问题后面章节内容再给大家讲)。

所以由此可知,inode table 表本身也需要占用磁盘的存储空间。每一个文件都有唯一的一个 inode,每一个 inode 都有一个与之相对应的数字编号,通过这个数字编号就可以找到 inode table 中所对应的 inode。

在 Linux 系统下,我们可以通过"ls -i"命令查看文件的 inode 编号,如下所示:

上图中 ls 打印出来的信息中,每一行前面的一个数字就表示了对应文件的 inode 编号。除此之外,还可以使用 stat 命令查看,用法如下:

由以上的介绍大家可以联系到实际操作中,譬如我们在 Windows 下进行 U 盘格式化的时候会有一个“快速格式化”选项,如下所示:

如果勾选了“快速格式化”选项,在进行格式化操作的时候非常的快,而如果不勾选此选项,直接使用普通格式化方式,将会比较慢,那说明这两种格式化方式是存在差异的,其实快速格式化只是删除了 U 盘中的 inode table 表,真正存储文件数据的区域并没有动,所以使用快速格式化的 U 盘,其中的数据是可以被找回来的。

通过以上介绍可知,打开一个文件,系统内部会将这个过程分为三步:

1) 系统找到这个文件名所对应的 inode 编号;

2) 通过 inode 编号从 inode table 中找到对应的 inode 结构体;

3) 根据 inode 结构体中记录的信息,确定文件数据所在的 block,并读出数据。

文件打开时的状态

当我们调用 open 函数去打开文件的时候,内核会申请一段内存(一段缓冲区),并且将静态文件的数据内容从磁盘这些存储设备中读取到内存中进行管理、缓存(也把内存中的这份文件数据叫做动态文件、内核缓冲区)。打开文件后,以后对这个文件的读写操作,都是针对内存中这一份动态文件进行相关的操作,而并不是针对磁盘中存放的静态文件。

当我们对动态文件进行读写操作后,此时内存中的动态文件和磁盘设备中的静态文件就不同步了,数据的同步工作由内核完成,内核会在之后将内存这份动态文件更新(同步)到磁盘设备中。由此我们也可以联系到实际操作中,譬如说:

⚫ 打开一个大文件的时候会比较慢;

⚫ 文档写了一半,没记得保存,此时电脑因为突然停电直接掉电关机了,当重启电脑后,打开编写的文档,发现之前写的内容已经丢失。

想必各位读者在工作当中都遇到过这种问题吧,通过上面的介绍,就解释了为什么会出现这种问题。

好,我们再来说一下,为什么要这样设计?

因为磁盘、硬盘、U 盘等存储设备基本都是 Flash 块设备,因为块设备硬件本身有读写限制等特征,块设备是以一块一块为单位进行读写的(一个块包含多个扇区,而一个扇区包含多个字节),一个字节的改动也需要将该字节所在的 block 全部读取出来进行修改,修改完成之后再写入块设备中,所以导致对块设备的读写操作非常不灵活;而内存可以按字节为单位来操作,而且可以随机操作任意地址数据,非常地很灵活,所以对于操作系统来说,会先将磁盘中的静态文件读取到内存中进行缓存,读写操作都是针对这份动态文件,而不是直接去操作磁盘中的静态文件,不但操作不灵活,效率也会下降很多,因为内存的读写速率远比磁盘读写快得多。

在 Linux 系统中,内核会为每个进程(关于进程的概念,这是后面的内容,我们可以简单地理解为一个运行的程序就是一个进程,运行了多个程序那就是存在多个进程)设置一个专门的数据结构用于管理该进程,譬如用于记录进程的状态信息、运行特征等,我们把这个称为进程控制块(Process control block,缩写PCB)。PCB 数据结构体中有一个指针指向了文件描述符表(File descriptors),文件描述符表中的每一个元素索引到对应的文件表(File table),文件表也是一个数据结构体,其中记录了很多文件相关的信息,譬如文件状态标志、引用计数、当前文件的读写偏移量以及 i-node 指针(指向该文件对应的 inode)等,进程打开的所有文件对应的文件描述符都记录在文件描述符表中,每一个文件描述符都会指向一个对应的文件表,其示意图如下所示:

前面给大家介绍了 inode,inode 数据结构体中的元素会记录该文件的数据存储的 block(块),也就是说可以通过 inode 找到文件数据存在在磁盘设备中的那个位置,从而把文件数据读取出来。

Linux下一切皆文件,不管是普通文件还是设备文件,操作时都会在内核有缓冲区,也就是在内存里操作,如果是放在flash中的普通文件,会先加载到内存中操作,在内存中读写结束时,还可以手动同步到flash中,或者由内核同步到flash中。 

空洞文件

什么是空洞文件(hole file)?在上一章内容中,笔者给大家介绍了 lseek()系统调用,使用 lseek 可以修改文件的当前读写位置偏移量,此函数不但可以改变位置偏移量,并且还允许文件偏移量超出文件长度,这是什么意思呢?譬如有一个 test_file,该文件的大小是 4K(也就是 4096 个字节),如果通过 lseek 系统调用将该文件的读写偏移量移动到偏移文件头部 6000 个字节处,大家想一想会怎样?如果笔者没有提前告诉大家,大家觉得不能这样操作,但事实上 lseek 函数确实可以这样操作。

接下来使用 write()函数对文件进行写入操作,也就是说此时将是从偏移文件头部 6000 个字节处开始写入数据,也就意味着 4096~6000 字节之间出现了一个空洞,因为这部分空间并没有写入任何数据,所以形成了空洞,这部分区域就被称为文件空洞,那么相应的该文件也被称为空洞文件。

文件空洞部分实际上并不会占用任何物理空间,直到在某个时刻对空洞部分进行写入数据时才会为它分配对应的空间,但是空洞文件形成时,逻辑上该文件的大小是包含了空洞部分的大小的,这点需要注意。

那说了这么多,空洞文件有什么用呢?空洞文件对多线程共同操作文件是及其有用的,有时候我们创建一个很大的文件,如果单个线程从头开始依次构建该文件需要很长的时间,有一种思路就是将文件分为多段,然后使用多线程来操作,每个线程负责其中一段数据的写入;这个有点像我们现实生活当中施工队修路的感觉,比如说修建一条高速公路,单个施工队修筑会很慢,这个时候可以安排多个施工队,每一个施工队负责修建其中一段,最后将他们连接起来。

来看一下实际中空洞文件的两个应用场景:

⚫ 在使用迅雷下载文件时,还未下载完成,就发现该文件已经占据了全部文件大小的空间,这也是空洞文件;下载时如果没有空洞文件,多线程下载时文件就只能从一个地方写入,这就不能发挥多线程的作用了;如果有了空洞文件,可以从不同的地址同时写入,就达到了多线程的优势;

⚫ 在创建虚拟机时,你给虚拟机分配了 100G 的磁盘空间,但其实系统安装完成之后,开始也不过只用了 3、4G 的磁盘空间,如果一开始就把 100G 分配出去,资源是很大的浪费。

关于空洞文件,这里就介绍这么多,以后真正用到时再补充。

非阻塞 I/O

到底什么是可读和可写?

  • 可读事件,当文件描述符关联的内核读缓冲区可读,则触发可读事件。 (可读:内核缓冲区非空,有数据可以读取)

  • 可写事件,当文件描述符关联的内核写缓冲区可写,则触发可写事件。 (可写:内核缓冲区不满,有空闲空间可以写入)

关于“阻塞”一词前面已经给大家多次提到,阻塞其实就是进入了休眠状态,交出了 CPU 控制权。前面所学习过的函数,譬如 wait()、pause()、sleep()等函数都会进入阻塞,本小节来聊一聊关于阻塞式 I/O 与非阻塞式 I/O。

阻塞式 I/O 顾名思义就是对文件的 I/O 操作(读写操作)是阻塞式的,非阻塞式 I/O 同理就是对文件的I/O 操作是非阻塞的。这样说大家可能不太明白,这里举个例子,譬如对于某些文件类型(读管道文件、网络设备文件和字符设备文件),当对文件进行读操作时,如果数据未准备好、文件当前无数据可读,那么读操作可能会使调用者阻塞,直到有数据可读时才会被唤醒,这就是阻塞式 I/O 常见的一种表现;如果是非阻塞式 I/O,即使没有数据可读,也不会被阻塞、而是会立马返回错误!

普通文件的读写操作是不会阻塞的,不管读写多少个字节数据,read()或 write()一定会在有限的时间内返回,所以普通文件一定是以非阻塞的方式进行 I/O 操作,这是普通文件本质上决定的;但是对于某些文件类型,譬如上面所介绍的管道文件、设备文件等,它们既可以使用阻塞式 I/O 操作,也可以使用非阻塞式 I/O进行操作。

阻塞 I/O 与非阻塞 I/O 读文件

本小节我们将分别演示使用阻塞式 I/O 和非阻塞式 I/O 对文件进行读操作,在调用 open()函数打开文件时,为参数 flags 指定 O_NONBLOCK 标志,open()调用成功后,后续的 I/O 操作将以非阻塞式方式进行;这就是非阻塞 I/O 的打开方式,如果未指定 O_NONBLOCK 标志,则默认使用阻塞式 I/O 进行操作。

对于普通文件来说,指定与未指定 O_NONBLOCK 标志对其是没有影响,普通文件的读写操作是不会阻塞的,它总是以非阻塞的方式进行 I/O 操作,这是普通文件本质上决定的,前面已经给大家进行了说明。

本小节我们将以读取鼠标为例,使用两种 I/O 方式进行读取,来进行对比,鼠标是一种输入设备,其对应的设备文件在/dev/input/目录下,如下所示:

示例代码 13.1.1 演示了以阻塞方式读取鼠标,调用 open()函数打开鼠标设备文件"/dev/input/event3",以只读方式打开,没有指定 O_NONBLOCK 标志,说明使用的是阻塞式 I/O;程序中只调用了一次 read()读取鼠标。

示例代码 13.1.1 阻塞式 I/O 读取鼠标数据 
#include <stdio.h> 
#include <stdlib.h> 
#include <sys/types.h> 
#include <sys/stat.h> 
#include <fcntl.h> 
#include <unistd.h> 
#include <string.h> 
int main(void) 
{ 
     char buf[100]; 
     int fd, ret; 
     /* 打开文件 */ 
     fd = open("/dev/input/event3", O_RDONLY); 
     if (-1 == fd) { 
         perror("open error"); 
         exit(-1);
     } 
     /* 读文件 */ 
     memset(buf, 0, sizeof(buf)); 
     ret = read(fd, buf, sizeof(buf)); 
     if (0 > ret) { 
         perror("read error"); 
         close(fd); 
         exit(-1); 
     } 
     printf("成功读取<%d>个字节数据\n", ret); 
     /* 关闭文件 */ 
     close(fd); 
     exit(0); 
}

编译上述示例代码进行测试:

执行程序之后,发现程序没有立即结束,而是一直占用了终端,没有输出信息,原因在于调用 read()之后进入了阻塞状态,因为当前鼠标没有数据可读;如果此时我们移动鼠标、或者按下鼠标上的任何一个按键,阻塞会结束,read()会成功读取到数据并返回,如下所示:

接下来,我们将示例代码 13.1.1 修改成非阻塞式 I/O,如下所示:

示例代码 13.1.2 非阻塞式 I/O 读取鼠标数据 
#include <stdio.h> 
#include <stdlib.h> 
#include <sys/types.h> 
#include <sys/stat.h> 
#include <fcntl.h> 
#include <unistd.h>
#include <string.h> 
int main(void) 
{ 
     char buf[100]; 
     int fd, ret; 
     /* 打开文件 */ 
     fd = open("/dev/input/event3", O_RDONLY | O_NONBLOCK); 
     if (-1 == fd) { 
         perror("open error"); 
         exit(-1); 
     } 
     /* 读文件 */ 
     memset(buf, 0, sizeof(buf)); 
     ret = read(fd, buf, sizeof(buf)); 
     if (0 > ret) { 
         perror("read error"); 
         close(fd); 
         exit(-1); 
     } 
     printf("成功读取<%d>个字节数据\n", ret); 
     /* 关闭文件 */ 
     close(fd); 
     exit(0); 
} 

修改方法很简单,只需在调用 open()函数时指定 O_NONBLOCK 标志即可,对上述示例代码进行编译测试:

执行程序之后,程序立马就结束了,并且调用 read()返回错误,提示信息为"Resource temporarily unavailable",意思就是说资源暂时不可用;原因在于调用 read()时,如果鼠标并没有移动或者被按下(没有发生输入事件),是没有数据可读,故而导致失败返回,这就是非阻塞 I/O。

可以对示例代码 13.1.2 进行修改,使用轮训方式不断地去读取,直到鼠标有数据可读,read()将会成功返回:

示例代码 13.1.3 轮训+非阻塞方式读取鼠标 
#include <stdio.h> 
#include <stdlib.h> 
#include <sys/types.h> 
#include <sys/stat.h> 
#include <fcntl.h> 
#include <unistd.h> 
#include <string.h> 
int main(void) 
{ 
     char buf[100]; 
     int fd, ret; 
     /* 打开文件 */ 
     fd = open("/dev/input/event3", O_RDONLY | O_NONBLOCK); 
     if (-1 == fd) { 
         perror("open error"); 
         exit(-1); 
     } 
     /* 读文件 */ 
     memset(buf, 0, sizeof(buf)); 
     for ( ; ; ) { 
         ret = read(fd, buf, sizeof(buf)); 
         if (0 < ret) { 
             printf("成功读取<%d>个字节数据\n", ret); 
             close(fd); 
             exit(0); 
         } 
     } 
} 

具体的执行的效果便不再演示了,各位读者自己动手试试。

阻塞 I/O 的优点与缺点

当对文件进行读取操作时,如果文件当前无数据可读,那么阻塞式 I/O 会将调用者应用程序挂起、进入休眠阻塞状态,直到有数据可读时才会解除阻塞;而对于非阻塞 I/O,应用程序不会被挂起,而是会立即返回,它要么一直轮训等待,直到数据可读,要么直接放弃!

所以阻塞式 I/O 的优点在于能够提升 CPU 的处理效率,当自身条件不满足时,进入阻塞状态,交出 CPU资源,将 CPU 资源让给别人使用;而非阻塞式则是抓紧利用 CPU 资源,譬如不断地去轮训,这样就会导致该程序占用了非常高的 CPU 使用率!

执行示例代码 13.1.3 对应的程序时,通过 top 命令可以发现该程序的占用了非常高的 CPU 使用率,如下所示:

其 CPU 占用率几乎达到了 100%,在一个系统当中,一个进程的 CPU 占用率这么高是一件非常危险的事情。而示例代码 13.1.1 这种阻塞式方式,其 CPU 占用率几乎为 0,所以就本章的所举例子来说,阻塞式I/O 绝地要优于非阻塞式 I/O,那既然如此,我们为何还要介绍非阻塞式 I/O 呢?

下一小节我们将通过一个例子给大家介绍,阻塞式 I/O 的困境!

使用非阻塞 I/O 实现并发读取

上一小节给大家所举的例子当中,只读取了鼠标的数据,如果要在程序当中同时读取鼠标和键盘,那又该如何呢?本小节我们将分别演示使用阻塞式 I/O 和非阻塞式 I/O 同时读取鼠标和键盘;同理键盘也是一种输入类设备,但是键盘是标准输入设备 stdin,进程会自动从父进程中继承标准输入、标准输出以及标准错误,标准输入设备对应的文件描述符为 0,所以在程序当中直接使用即可,不需要再调用 open 打开。

首先我们使用阻塞式方式同时读取鼠标和键盘,示例代码如下所示:

示例代码 13.1.4 阻塞式同时读取鼠标和键盘 
#include <stdio.h> 
#include <stdlib.h> 
#include <sys/types.h> 
#include <sys/stat.h> 
#include <fcntl.h> 
#include <unistd.h> 
#include <string.h> 
#define MOUSE "/dev/input/event3" 
int main(void) 
{ 
     char buf[100]; 
     int fd, ret; 
     /* 打开鼠标设备文件 */ 
     fd = open(MOUSE, O_RDONLY); 
     if (-1 == fd) { 
         perror("open error"); 
         exit(-1); 
     }
     /* 读鼠标 */ 
     memset(buf, 0, sizeof(buf)); 
     ret = read(fd, buf, sizeof(buf)); 
     printf("鼠标: 成功读取<%d>个字节数据\n", ret); 
     /* 读键盘 */ 
     memset(buf, 0, sizeof(buf)); 
     ret = read(0, buf, sizeof(buf)); 
     printf("键盘: 成功读取<%d>个字节数据\n", ret); 
     /* 关闭文件 */ 
     close(fd); 
     exit(0); 
}

上述程序中先读了鼠标,在接着读键盘,所以由此可知,在实际测试当中,需要先动鼠标在按键盘(按下键盘上的按键、按完之后按下回车),这样才能既成功读取鼠标、又成功读取键盘,程序才能够顺利运行结束。因为 read 此时是阻塞式读取,先读取了鼠标,没有数据可读将会一直被阻塞,后面的读取键盘将得不到执行。

这就是阻塞式 I/O 的一个困境,无法实现并发读取(同时读取),主要原因在于阻塞,那如何解决这个问题呢?当然大家可能会想到使用多线程,一个线程读取鼠标、另一个线程读取键盘,亦或者创建一个子进程,父进程读取鼠标、子进程读取键盘等方法,当然这些方法自然可以解决,但不是我们要学习的重点。

既然阻塞 I/O 存在这样一个困境,那我们可以使用非阻塞式 I/O 解决它,将示例代码 13.1.4 修改为非阻塞式方式同时读取鼠标和键盘。使用 open()打开得到的文件描述符,调用 open()时指定 O_NONBLOCK 标志将其设置为非阻塞式 I/O;因为标准输入文件描述符(键盘)是从其父进程进程而来,并不是在我们的程序中调用 open()打开得到的,那如何将标准输入设置为非阻塞 I/O,可以使用 3.10.1 小节中给大家介绍的fcntl()函数,具体使用方法在该小节中已有详细介绍,这里不再重述!可通过如下代码将标准输入(键盘)设置为非阻塞方式:

int flag; 
flag = fcntl(0, F_GETFL); //先获取原来的 flag 
flag |= O_NONBLOCK; //将 O_NONBLOCK 标志添加到 flag 
fcntl(0, F_SETFL, flag); //重新设置 flag

示例代码 13.1.5 演示了以非阻塞方式同时读取鼠标和键盘。

示例代码 13.1.5 非阻塞式方式同时读取鼠标和键盘 
#include <stdio.h> 
#include <stdlib.h> 
#include <sys/types.h> 
#include <sys/stat.h> 
#include <fcntl.h> 
#include <unistd.h> 
#define MOUSE "/dev/input/event3"
int main(void) 
{ 
     char buf[100]; 
     int fd, ret, flag; 
     /* 打开鼠标设备文件 */ 
     fd = open(MOUSE, O_RDONLY | O_NONBLOCK); 
     if (-1 == fd) { 
         perror("open error"); 
         exit(-1); 
     } 
     /* 将键盘设置为非阻塞方式 */ 
     flag = fcntl(0, F_GETFL); //先获取原来的 flag 
     flag |= O_NONBLOCK; //将 O_NONBLOCK 标准添加到 flag 
     fcntl(0, F_SETFL, flag); //重新设置 flag 
     for ( ; ; ) { 
         /* 读鼠标 */ 
         ret = read(fd, buf, sizeof(buf)); 
         if (0 < ret) 
             printf("鼠标: 成功读取<%d>个字节数据\n", ret); 
         /* 读键盘 */ 
         ret = read(0, buf, sizeof(buf)); 
         if (0 < ret) 
             printf("键盘: 成功读取<%d>个字节数据\n", ret); 
     } 
     /* 关闭文件 */ 
     close(fd); 
     exit(0); 
} 

将读取鼠标和读取键盘操作放入到一个循环中,通过轮训方式来实现并发读取鼠标和键盘,对上述代码进行编译,测试结果:

这样就解决了示例代码 13.1.4 所出现的问题,不管是先动鼠标还是先按键盘都可以成功读取到相应数据。

虽然使用非阻塞 I/O 方式解决了示例代码 13.1.4 出现的问题,但由于程序当中使用轮训方式,故而会使得该程序的 CPU 占用率特别高,终归还是不太安全,会对整个系统产生很大的副作用,如何解决这样的问题呢?

可以使用IO多路复用,这个参考我的另一篇文章:

Linux-IO多路复用-CSDN博客

非连续多缓冲区IO 

非连续多缓冲区IO:readv和writev函数

参考:

Unix/Linux编程:分散输入和集中输出------readv() 、 writev()_preadv-CSDN博客

/*
		* 参数: fd    文件描述符
		*       iov    指向iovec结构数组的一个指针
		*       iovcnt  指定了iovec的个数
		* 返回值:函数调用成功时返回读、写的总字节数,失败时返回-1并设置相应的errno。
		* 			writev返回输出的字节总数,通常应等于所有缓冲区长度之和。
		* 			readv返回读到的字节总数。如果遇到文件尾端,已无数据可读,则返回0。
		*/
       #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);

        
        readv() 系统调用从关联的文件读取iovcnt缓冲区文件描述符fd
		        放入iov描述的缓冲区(“分散输入”)

        writev() 系统调用将iov描述的数据的iovcnt缓冲区写入与文件描述符fd
       			相关联的文件(“聚集输出”)

		结构体指针iov定义了一组用来传输数据的缓存区.
		整数iovcnt则指定了iov的成员个数。
		iov中的每个成员都是如下形式的数据结构

		#include <sys/uio.h>
		struct iovec {
               void  *iov_base;    /* 缓冲区首地址 */
               size_t iov_len;     /*缓冲区长度 */
         };

		readv()系统调用的工作方式与read(2)相同,只是要填充多个缓冲区。
		
		writev()系统调用的工作方式与write(2)相同,只是要写出多个缓冲区 

		缓冲区以数组顺序处理。 这意味着readv()在完全填充iov[0]之前不会
		进入iov[1],依此类推。(如果数据不足,则可能不会填充iov指向的
		所有缓冲区)。类似地,writev()在进行iov[1]之前写出iov[0]的全部内容,
		依此类推。 
		
		readv() 和writev()执行的数据传输是原子的

readv和write

  • readv/writevrecvmsg/sendmsg的简化版,主要针对与文件IO(对read/write的优化)
  • readv和writev函数用于在一次函数调用中读、写多个非连续缓冲区。有时也将这两个函数称为散布读和聚集写

在一次函数调用中:
① writev以顺序iov[0]、iov[1]至iov[iovcnt-1]从各缓冲区中聚集输出数据到fd
② readv则将从fd读入的数据按同样的顺序散布到各缓冲区中,readv总是先填满一个缓冲区,然后再填下一个

为什么引入readv()和writev()

(1) 使用read函数将数据读到不连续的内存,或者wirte将不连续的内存发送出去,要多次调用read、write

如果要从文件中读一片连续的数据至进程的不同区域,有两种方案
① 使用read()一次将它们读至一个较大的缓冲区中,然后将它们分成若干部分复制到不同的区域;
② 调用read()若干次分批将它们读至不同区域。

同样,如果想将程序中不同区域的数据块连续地写至文件,也必须进行类似的处理。

(2) UNIX提供了另外两个函数—readv()和writev(),它们只需一次系统调用就可以实现在文件和进程的多个缓冲区之间传送数据,免除了多次系统调用或复制数据的开销。

分散输入

readv()实现了分散输入的功能:从文件描述符fd所指代的文件中读取一片连续的字节,然后将其分散放置到iov指定的缓存区中。这一散置动作从 iov[0]开始,依次填满整个缓存区

readv()是原子性的。

调用 readv()成功将返回读取的字节数,若文件结束1将返回 0。调用者必须对返回值进行检查,以验证读取的字节数是否满足要求。若数据不足以填充所有缓冲区,则只会占用2部分缓冲区,其中最后一个缓冲区可能只存有部分数据

例子1:读文件

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/uio.h>
#include <fcntl.h>

int main(int argc, char *argv[]){
    int fd;
    struct iovec iov[3];
    struct stat myStruct;       /* First buffer */
    int x;                      /* Second buffer */
#define STR_SIZE 100
    char str[STR_SIZE];         /* Third buffer */
    ssize_t numRead, totRequired;

    if (argc != 2 || strcmp(argv[1], "--help") == 0){
        fprintf(stderr, "%s file\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    fd = open(argv[1], O_RDONLY);
    if(fd == -1){
        fprintf(stderr, "open\n");
        exit(EXIT_FAILURE);
    }

    totRequired = 0;

    iov[0].iov_base = &myStruct;
    iov[0].iov_len = sizeof(struct stat);
    totRequired += iov[0].iov_len;

    iov[1].iov_base = &x;
    iov[1].iov_len = sizeof(x);
    totRequired += iov[1].iov_len;

    iov[2].iov_base = str;
    iov[2].iov_len = STR_SIZE;
    totRequired += iov[2].iov_len;

    numRead = readv(fd, iov, 3);
    if (numRead == -1){
        fprintf(stderr, "readv\n");
        exit(EXIT_FAILURE);
    }

    if (numRead < totRequired)
        printf("Read fewer bytes than requested\n");

    printf("total bytes requested: %ld; bytes read: %ld\n",
           (long) totRequired, (long) numRead);

    printf("%s", str);
    exit(EXIT_SUCCESS);
}
#include <stdio.h>
#include <sys/uio.h>
#include <fcntl.h>
 
int main(void){
        char buf1[5],buf2[10];
        struct iovec iov[2];
        iov[0].iov_base = buf1;
        iov[0].iov_len = 5;
        iov[1].iov_base = buf2;
        iov[1].iov_len = 10;
 
        int fd = open("a.txt",O_RDWR);
        if(fd < 0){
                perror("open");
                return -1;
        }
        int rsize = readv(fd, iov, 2);  // 从文件a.txt中读取数据,存到iov[2]中的buf1、buf2
        printf("rsize = %d\n",rsize);
 
        close(fd);
 
        fd = open("b.txt", O_RDWR|O_CREAT, S_IRUSR|S_IWUSR);
        if(fd < 0){
                perror("open");
                return -1;
        }
 
        int wsize = writev(fd,iov,2);  // 将iov[2]中的buf1、buf2,写入到文件b.txt
        printf("wsize = %d\n",wsize);
 
        close(fd);
        return 0;
}

集中输出

writev()系统调用实现了集中输出:将iov所指定的所有缓存区中的数据拼接(“集中”)起来,然后以连续的字节序列写入文件描述符fd指代的文件中。对缓冲区中数据的“集中”始于iov[0]所指定的缓冲区,并按数组顺序展开

像readv()调用一样,writev()调用也属于原子操作,即所有数据将一次性的从用户内存传入到fd指代的文件中。因此,在向普通文件写入数据时,writev()调用会把所有的请求数据连续写入文件,而即不受其他进(线)程改变文件偏移量的影响

如同 write()调用,writev()调用也可能存在部分写的问题。因此,必须检查 writev()调用的返回值,以确定写入的字节数是否与要求相符。

readv()调用和 writev()调用的主要优势在于便捷。如下两种方案,任选其一都可替代对writev()的调用。

  • 编码时,首先分配一个大缓冲区,随即再从进程地址空间的其他位置将数据复制过来,最后调用 write()输出其中的所有数据。
  • 发起一系列 write()调用,逐一输出每个缓冲区中的数据

尽管方案一在语义上等同于 writev()调用,但需要在用户空间内分配缓冲区,进行数据复制,很不方便(效率也低)。

方案二在语义上就不同于单次的 writev()调用,因为发起多次 write()调用将无法保证原子性。更何况,执行一次 writev()调用比执行多次 write()调用开销要小(参见 3.1 节关于系统调用的讨论)

应当指出,readv()和 writev()会改变打开文件句柄的当前文件偏移量

例子1:

writev:指定了两个缓冲区,str0和str1,内容输出到标准输出,并打印实际输出的字节数

// writevex.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/uio.h>

int main()
{
    char *str0 = "hello ";
    char *str1 = "world\n";
    struct iovec iov[2];
    ssize_t nwritten;

    iov[0].iov_base = str0;
    iov[0].iov_len = strlen(str0) + 1;
    iov[1].iov_base = str1;
    iov[1].iov_len = strlen(str1) + 1;

    nwritten = writev(STDOUT_FILENO, iov, 2);
    printf("%ld bytes written.\n", nwritten);

    exit(EXIT_SUCCESS);
}

例子2:读写文件

#include <stdio.h>
#include <sys/uio.h>
#include <fcntl.h>
 
int main(void){
        char buf1[5],buf2[10];
        struct iovec iov[2];
        iov[0].iov_base = buf1;
        iov[0].iov_len = 5;
        iov[1].iov_base = buf2;
        iov[1].iov_len = 10;
 
        int fd = open("a.txt",O_RDWR);
        if(fd < 0){
                perror("open");
                return -1;
        }
        int rsize = readv(fd, iov, 2);  // 从文件a.txt中读取数据,存到iov[2]中的buf1、buf2
        printf("rsize = %d\n",rsize);
 
        close(fd);
 
        fd = open("b.txt", O_RDWR|O_CREAT, S_IRUSR|S_IWUSR);
        if(fd < 0){
                perror("open");
                return -1;
        }
 
        int wsize = writev(fd,iov,2);  // 将iov[2]中的buf1、buf2,写入到文件b.txt
        printf("wsize = %d\n",wsize);
 
        close(fd);
        return 0;
}

更多待补充。

打开关闭文件描述符等过程一般只需要执行一次,但是读写监听等一般都是放在任务的循环里 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值