赞
踩
本篇文章方便读者入门linux应用编程,了解相关概念。也可以把本篇文章当作工具书,遇到不懂的概念就进行查询了解。文章中的内容时学习linux必须掌握的基础知识,无论是做驱动开发还是应用开发,建议收藏!
系统调用(system call) 其实是 Linux 内核提供给应用层的应用编程接口(API) , 是 Linux 应用层进入内核的入口。不止 Linux 系统,所有的操作系统都会向应用层提供系统调用,应用程序通过系统调用来使用操作系统提供的各种服务。通过系统调用, Linux 应用程序可以请求内核以自己的名义执行某些事情,譬如打开磁盘中的文件、读写文件、关闭文件以及控制其它硬件外设。内核提供了一系列的服务、资源、支持一系列功能,应用程序通过调用系统调用 API 函数来使用内核提供的服务、资源以及各种各样的功能, 如果大家接触过其它操作系统编程,想必对此并不陌生,譬如Windows 应用编程,操作系统内核一般都会向应用程序提供应用编程接口 API,否则我们将我无法使用操作系统。
裸机编程:就像大家熟悉的stm32,一般把没有操作系统支持的编程环境称为裸机编程环境。
linux驱动编程:基于内核驱动框架开发驱动程序, 驱动开发工程师通过调用 Linux 内核提供的接口完成设备驱动的注册, 驱动程序负责底层硬件操作相关逻辑,驱动程序处于内核态。
linux应用编程:基于 Linux 操作系统的应用编程,在应用程序中通过调用系统调用 API 完成应用程序的功能和逻辑, 应用程序运行于操作系统之上。应用程序运行在用户态。
应用编程中最基础的知识,即文件 I/O(Input、 Outout) , 文件 I/O 指的是对文件的输入/输出操作,说白了就是对文件的读写操作; Linux 下一切皆文件,文件作为 Linux 系统设计思想的核心理念,在 Linux 系统下显得尤为重要,所以对文件的 I/O 操作既是基础也是最重要的部分。
一个通用的 IO 模型通常包括打开文件、读写文件、关闭文件这些基本操作, 主要涉及到 4 个函数: open()、 read()、 write()以及 close()。这些函数是我们平时编程中经常用到的,必须要会使用的四个函数。
文件描述符:对于 Linux 内核而言,所有打开的文件都会通过文件描述符进行索引 ,每一个被打开的文件在同一个进程中都有一个唯一的文件描述符,不会重复,如果文件被关闭后,它对应的文件描述符将会被释放,那么这个文件描述符将可以再次分配给其它打开的文件、与对应的文件绑定起来。
本章带大家深入了解了文件 I/O 中的一些细节,譬如文件的管理方式、
错误返回的处理、空洞文件、 O_APPEND 和 O_TRUNC 标志、原子操作与竞争冒险等等。
文件在没有被打开的情况下一般都是存放在磁盘中的,譬如电脑硬盘、移动硬盘、 U 盘等外部存储设备, 文件存放在磁盘文件系统中,并且以一种固定的形式进行存放,我们把他们称为静态文件。
文件储存在硬盘上, 硬盘的最小存储单位叫做“扇区” (Sector), 每个扇区储存 512 字节(相当于 0.5KB),操作系统读取硬盘的时候,不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个“块” (block)。这种由多个扇区组成的“块” ,是文件存取的最小单位。 “块” 的大小,最常见的是 4KB,即连续八个 sector 组成一个 block。
我们的磁盘在进行分区、格式化的时候会将其分为两个区域,一个是数据区,用于存储文件中的数据;另一个是 inode 区,用于存放 inode table(inode 表), inode table 中存放的是一个一个的 inode(也成为 inode节点),不同的 inode 就可以表示不同的文件,每一个文件都必须对应一个 inode, inode 实质上是一个结构体,这个结构体中有很多的元素,不同的元素记录了文件了不同信息,譬如文件字节大小、文件所有者、文件对应的读/写/执行权限、文件时间戳(创建时间、更新时间等)、 文件类型、 文件数据存储的 block(块)位置等等信息
inode table 表本身也需要占用磁盘的存储空间。 每一个文件都有唯一的一个 inode, 每一个 inode 都有一个与之相对应的数字编号,通过这个数字编号就可以找到 inode table 中所对应的 inode。
打开一个文件,系统内部会将这个过程分为三步:
当我们调用 open 函数去打开文件的时候,内核会申请一段内存(一段缓冲区) ,并且将静态文件的数据内容从磁盘这些存储设备中读取到内存中进行管理、 缓存(也把内存中的这份文件数据叫做动态文件、内核缓冲区)。打开文件后,以后对这个文件的读写操作,都是针对内存中这一份动态文件进行相关的操作,而并不是针对磁盘中存放的静态文件。当我们对动态文件进行读写操作后,此时内存中的动态文件和磁盘设备中的静态文件就不同步了, 数据的同步工作由内核完成,内核会在之后将内存这份动态文件更新(同步)到磁盘设备中。 这就是为什么我们平时没有保存word文档就关机的时候内容就会丢失。
原理:磁盘、硬盘、 U 盘等存储设备基本都是 Flash 块设备,因为块设备硬件本身有读写限制等特征,块设备是以一块一块为单位进行读写的(一个块包含多个扇区,而一个扇区包含多个字节) ,一个字节的改动也需要将该字节所在的 block 全部读取出来进行修改,修改完成之后再写入块设备中, 所以导致对块设备的读写操作非常不灵活; 而内存可以按字节为单位来操作,而且可以随机操作任意地址数据,非常地很灵活,所以对于操作系统来说,会先将磁盘中的静态文件读取到内存中进行缓存,读写操作都是针对这份动态文件,而不是直接去操作磁盘中的静态文件,不但操作不灵活,效率也会下降很多,因为内存的读写速率远比磁盘读写快得多。
在 Linux 系统中, 内核会为每个进程(关于进程的概念,这是后面的内容,我们可以简单地理解为一个运行的程序就是一个进程,运行了多个程序那就是存在多个进程) 设置一个专门的数据结构用于管理该进程,譬如用于记录进程的状态信息、运行特征等,我们把这个称为进程控制块(Process control block,缩写PCB)。
PCB 数据结构体中有一个指针指向了文件描述符表(File descriptors), 文件描述符表中的每一个元素索引到对应的文件表(File table),文件表也是一个数据结构体,其中记录了很多文件相关的信息,譬如文件状态标志、 引用计数、 当前文件的读写偏移量以及 i-node 指针(指向该文件对应的 inode)等, 进程打开的所有文件对应的文件描述符都记录在文件描述符表中,每一个文件描述符都会指向一个对应的文件表
使用 write()函数对文件进行写入操作,从文件头部开始写入4000个字节,然后从偏移文件头部 6000 个字节处开始写入数据,也就意味着 4000~6000 字节之间出现了一个空洞, 因为这部分空间并没有写入任何数据,所以形成了空洞,这部分区域就被称为文件空洞,那么相应的该文件也被称为空洞文件。
文件空洞部分实际上并不会占用任何物理空间,直到在某个时刻对空洞部分进行写入数据时才会为它分配对应的空间,但是空洞文件形成时,逻辑上该文件的大小是包含了空洞部分的大小的
应用:空洞文件对多线程共同操作文件是及其有用的,有时候我们创建一个很大的文件,如果单个线程从头开始依次构建该文件需要很长的时间,有一种思路就是将文件分为多段,然后使用多线程来操作,每个线程负责其中一段数据的写入;这个有点像我们现实生活当中施工队修路的感觉,比如说修建一条高速公路,单个施工队修筑会很慢,这个时候可以安排多个施工队,每一个施工队负责修建其中一段,最后将他们连接起来。
Linux 是一个多任务、多进程操作系统,系统中往往运行着多个不同的进程、任务, 多个不同的进程就有可能对同一个文件进行 IO 操作,此时该文件便是它们的共享资源,它们共同操作着同一份文件;操作系统级编程不同于大家以前接触的裸机编程,裸机程序中不存在进程、多任务这种概念, 而在 Linux 系统中,我们必须要留意到多进程环境下可能会导致的竞争冒险。竞争冒险不但存在于 Linux 应用层、也存在于 Linux 内核驱动层。
竞争冒险举例:假设有两个独立的进程 A 和进程 B 都对同一个文件进行追加写操作(也就是在文件末尾写入数据) ,每一个进程都调用了 open 函数打开了该文件,此时,每个进程都有它自己的进程控制块 PCB,有自己的文件表(意味着有自己独立的读写位置偏移量) ,但是共享同一个 inode 节点(也就是对应同一个文件)。假定此时进程 A 处于运行状态, B 未处于等待运行状态,进程 A 将当前位置偏移量设置为 1500 字节处(假设这里是文件末尾) ,刚好此时进程 A 的时间片耗尽,然后内核切换到了进程 B,进程 B 也将其对该文件的当前位置偏移量设置为 1500 个字节处(文件末尾) 。然后进程 B 调用 write 函数,写入了 100 个字节数据, 那么此时在进程 B 中,该文件的当前位置偏移量已经移动到了 1600 字节处。 B 进程时间片耗尽,内核又切换到了进程 A,使进程 A 恢复运行,当进程 A 调用 write 函数时,是从进程 A 的该文件当前位置偏移量(1500 字节处)开始写入, 此时文件 1500 字节处已经不再是文件末尾了,如果还从 1500字节处写入就会覆盖进程 B 刚才写入到该文件中的数据。
操作共享资源的两个进程(或线程),其操作之后的所得到的结果往往是不可预期的, 因为每个进程(或线程)去操作文件的顺序是不可预期的,即这些进程获得 CPU 使用权的先后顺序是不可预期的,完全由操作系统调配, 这就是所谓的竞争
状态。
上述的问题出在逻辑操作“先定位到文件末尾,然后再写”,它使用了两个分开的函数调用,首先将文件当前位置偏移量移动到文件末尾、然后在使用 write函数将数据写入到文件。 既然知道了问题所在,那么解决办法就是将这两个操作步骤合并成一个原子操作,所谓原子操作, 是有多步操作组成的一个操作,原子操作要么一步也不执行,一旦执行, 必须要执行完所有步骤,不可能只执行所有步骤中的一个子集。
所谓标准 I/O 库则是标准 C 库中用于文件 I/O 操作(譬如读文件、写文件等)相关的一系列库函数的集合。标准 I/O 库函数是构建于文件 I/O(open()、 read()、 write()、 lseek()、 close()等)这些系统调用之上的,譬如标准 I/O 库函数 fopen()就利用系统调用 open()来执行打开文件的操作、 fread()利用系统调用 read()来执行读文件操作、 fwrite()则利用系统调用 write()来执行写文件操作等等。
那既然如此,为何还需要设计标准 I/O 库?直接使用文件 I/O 系统调用不是更好吗?事实上,并非如此, 设计库函数是为了提供比底层系统调用更为方便、好用的调用接口, 虽然标准 I/O 构建于文件 I/O 之上, 但标准 I/O 却有它自己的优势,标准 I/O 和文件 I/O 的区别如下:
1.虽然标准 I/O 和文件 I/O 都是 C 语言函数,但是标准 I/O 是标准 C 库函数,而文件 I/O 则是 Linux系统调用;
2.标准 I/O 是由文件 I/O 封装而来,标准 I/O 内部实际上是调用文件 I/O 来完成实际操作的;
3.可移植性:标准 I/O 相比于文件 I/O 具有更好的可移植性,通常对于不同的操作系统,其内核向应用层提供的系统调用往往都是不同,譬如系统调用的定义、功能、参数列表、返回值等往往都是不一样的;而对于标准 I/O 来说,由于很多操作系统都实现了标准 I/O 库,标准 I/O 库在不同的操作系统之间其接口定义几乎是一样的,所以标准 I/O 在不同操作系统之间相比于文件 I/O 具有更好的可移植性。
4.性能、效率: 标准 I/O 库在用户空间维护了自己的 stdio 缓冲区, 所以标准 I/O 是带有缓存的,而文件 I/O 在用户空间是不带有缓存的,所以在性能、效率上, 标准 I/O 要优于文件 I/O系统调用;
所有文件 I/O 函数(open()、 read()、 write()、 lseek()等)都是围绕文件描述符进行的,当调用 open()函数打开一个文件时,即返回一个文件描述符 fd,然后该文件描述符就用于后续的 I/O 操作。而对于标准 I/O 库函数来说,它们的操作是围绕 FILE 指针进行的,当使用标准 I/O 库函数打开或创建一个文件时,会返回一个指向 FILE 类型对象的指针(FILE *) ,使用该 FILE 指针与被打开或创建的文件相关联,然后该 FILE 指针就用于后续的标准 I/O 操作(使用标准 I/O 库函数进行 I/O 操作),所以由此可知,FILE 指针的作用相当于文件描述符,只不过 FILE 指针用于标准 I/O 库函数中、而文件描述符则用于文件I/O 系统调用中。
FILE 是一个结构体数据类型,它包含了标准 I/O 库函数为管理文件所需要的所有信息,包括用于实际I/O 的文件描述符、指向文件缓冲区的指针、缓冲区的长度、当前缓冲区中的字节数以及出错标志等。 FILE数据结构定义在标准 I/O 库函数头文件 stdio.h 中。
出于速度和效率的考虑,系统 I/O 调用(即文件 I/O, open、 read、 write 等)和标准 C 语言库 I/O 函数(即标准 I/O 函数)在操作磁盘文件时会对数据进行缓冲
read()和 write()系统调用在进行文件读写操作的时候并不会直接访问磁盘设备,而是仅仅在用户空间缓冲区和内核缓冲区(kernel buffer cache)之间复制数据。后面的某个时刻,内核会将其缓冲区中的数据写入(刷新)到磁盘设备中,所以由此知,系统调用 write()与磁盘操作并不是同步的, write()函数并不会等待数据真正写入到磁盘之后再返回。如果在此期间, 其它进程调用 read()函数读取该文件的这几个字节数据,那么内核将自动从缓冲区中读取这几个字节数据返回给应用程序。与此同理,对于读文件而言亦是如此,内核会从磁盘设备中读取文件的数据并存储到内核的缓冲区中,当调用 read()函数读取数据时, read()调用将从内核缓冲区中读取数据,直至把缓冲区中的数据读完,这时,内核会将文件的下一段内容读入到内核缓冲区中进行缓存。
我们把这个内核缓冲区就称为文件 I/O 的内核缓冲。这样的设计,目的是为了提高文件 I/O 的速度和效率,使得系统调用 read()、 write()的操作更为快速,不需要等待磁盘操作(将数据写入到磁盘或从磁盘读取出数据),磁盘操作通常是比较缓慢的。同时这一设计也更为高效,减少了内核操作磁盘的次数,譬如线程1 调用 write()向文件写入数据"abcd",线程 2 也调用 write()向文件写入数据"1234",这样的话,数据"abcd"和"1234"都被缓存在了内核的缓冲区中,在稍后内核会将它们一起写入到磁盘中,只发起一次磁盘操作请求;如果没有内核缓冲区,那么每一次调用 write(),内核就会执行一次磁盘操作。文件 I/O 的内核缓冲区自然是越大越好, Linux 内核本身对内核缓冲区的大小没有固定上限。内核会分配尽可能多的内核来作为文件 I/O 的内核缓冲区,但受限于物理内存的总量,如果系统可用的物理内存越多,那自然对应的内核缓冲区也就越大,操作越大的文件也要依赖于更大空间的内核缓冲。
标准 I/O(fopen、 fread、 fwrite、 fclose、 fseek 等)是 C 语言标准库函数, 而文件 I/O(open、 read、 write、close、 lseek 等)是系统调用,虽然标准 I/O 是在文件 I/O 基础上进行封装而实现(譬如 fopen 内部实际上调用了 open、 fread 内部调用了 read 等), 但在效率、性能上标准 I/O 要优于文件 I/O,其原因在于标准 I/O 实现维护了自己的缓冲区, 我们把这个缓冲区称为 stdio 缓冲区。
应用程序中通过标准 I/O 操作磁盘文件时,为了减少调用系统调用的次数,标准 I/O 函数会将用户写入或读取文件的数据缓存在 stdio 缓冲区,然后再一次性将 stdio 缓冲区中缓存的数据通过调用系统调用 I/O(文件 I/O)写入到文件 I/O 内核缓冲区或者拷贝到应用程序的 buf 中。通过这样的优化操作,当操作磁盘文件时,在用户空间缓存大块数据以减少调用系统调用的次数,使得效率、性能得到优化。 使用标准 I/O 可以使编程者免于自行处理对数据的缓冲,无论是调用 write()写入数据、还是调用 read()读取数据。
当我们使用ls -l查看一个文件的信息时,会出现如下的信息:
在 Linux 系统下,可以通过 stat 命令或者 ls 命令来查看文件类型,根据打印出来的第一个字符判断文件类型:
1.普通文件
普通文件可以分为两大类:文本文件和二进制文件。普通文件(regular file)在 Linux 系统下是最常见的,譬如文本文件、二进制文件,我们编写的源代码文件这些都是普通文件,也就是一般意义上的文件。 普通文件中的数据存在系统磁盘中,可以访问文件中的内容,文件中的内容以字节为单位进行存储于访问。
2.目录文件
3.字符设备文件和块设备文件
Linux 系统下,一切皆文件,也包括各种硬件设备。 设备文件(字符设备文件、块设备文件)对应的是硬件设备,在 Linux 系统中,硬件设备会对应到一个设备文件,应用程序通过对设备文件的读写来操控、使用硬件设备。虽然有设备文件,但是设备文件并不对应磁盘上的一个文件,也就是说设备文件并不存在于磁盘中,而是由文件系统虚拟出来的,一般是由内存来维护, 当系统关机时,设备文件都会消失; 字符设备文件一般存放在 Linux 系统/dev/目录下,所以/dev 也称为虚拟文件系统 devfs。
4.符号链接文件
符号链接文件(link) 类似于 Windows 系统中的快捷方式文件,是一种特殊文件,它的内容指向的是另一个文件路径,当对符号链接文件进行操作时,系统根据情况会对这个操作转移到它指向的文件上去,而不是对它本身进行操作,譬如,读取一个符号链接文件内容时,实际上读到的是它指向的文件的内容。
5.管道文件
管道文件(pipe) 主要用于进程间通信。
6.套接字文件
套接字文件(socket)也是一种进程间通信的方式,与管道文件不同的是,它们可以在不同主机上的进程间通信,实际上就是网络通信。
Linux 是一个多用户操作系统, 系统中一般存在着好几个不同的用户,而 Linux 系统中的每一个文件都有一个与之相关联的用户和用户组, 通过这个信息可以判断文件的所有者和所属组。文件所有者表示该文件属于“谁”,也就是属于哪个用户。一般来说文件在创建时,其所有者就是创建该文件的那个用户。
文件所属组则表示该文件属于哪一个用户组。在 Linux 中,系统并不是通过用户名或用户组名来识别不同的用户和用户组,而是通过 ID。 ID 就是一个编号, Linux 系统会为每一个用户或用户组分配一个 ID, 将用户名或用户组名与对应的 ID 关联起来, 所以系统通过用户 ID(UID) 或组 ID(GID) 就可以识别出不同的用户和用户组。
首先对于有效用户 ID 和有效组 ID 来说,这是进程所持有的概念,对于文件来说,并无此属性! 有效用户 ID 和有效组 ID 是站在操作系统的角度,用于给操作系统判断当前执行该进程的用户在当前环境下对某个文件是否拥有相应的权限。通常, 绝大部分情况下,进程的有效用户等于实际用户(有效用户 ID 等于实际用户 ID) 有效组等于实际组(有效组 ID 等于实际组 ID) 。
在 Linux 系统中有两种链接文件,分为软链接(也叫符号链接)文件和硬链接文件,软链接文件也就是前面给大家的 Linux 系统下的七种文件类型之一,其作用类似于 Windows 下的快捷方式。那么硬链接文件又是什么呢?
使用 ln 命令创建的两个硬链接文件与源文件 test_file 都拥有相同的 inode 号, 既然
inode 相同,也就意味着它们指向了物理硬盘的同一个区块,仅仅只是文件名字不同而已,创建出来的硬链接文件与源文件对文件系统来说是完全平等的关系。那么大家可能要问了,如果删除了硬链接文件或源文件其中之一,那文件所对应的 inode 以及文件内容在磁盘中的数据块会被文件系统回收吗? 事实上并不会这样,因为 inode 数据结结构中会记录文件的链接数,这个链接数指的就是硬链接数, struct stat 结构体中的st_nlink 成员变量就记录了文件的链接数当为文件每创建一个硬链接, inode 节点上的链接数就会加一,每删除一个硬链接, inode 节点上的链接数就会减一,直到为 0, inode 节点和对应的数据块才会被文件系统所回收,也就意味着文件已经从文件系统中被删除了。注意:源文件 test_file 本身就是一个硬链接文件。
软链接文件与源文件有着不同的 inode 号,所以也就是意味着它们之间有着不同的数据块,但是软链接文件的数据块中存储的是源文件的路径名,链接文件可以通过这个路径找到被链接的源文件,它们之间类似于一种“主从”关系当源文件被删除之后,软链接文件依然存在,但此时它指向的是一个无效的文件路径, 这种链接文件被称为悬空链接。
目录(文件夹) 在 Linux 系统也是一种文件, 是一种特殊文件,同样可以使用前面给大家介绍 open、read 等这些系统调用以及 C 库函数对其进行操作,但是目录作为一种特殊文件,并不适合使用前面介绍的文件 I/O 方式进行读写等操作。在 Linux 系统下,会有一些专门的系统调用或 C 库函数用于对文件夹进行操作。
其实目录在文件系统中的存储方式与常规文件类似,常规文件包括了 inode 节点以及文件内容数据存储块(block)对于目录来说, 其存储形式则是由 inode 节点和目录块所构成,目录块当中记录了有哪些文件组织在这个目录下,记录它们的文件名以及对应的 inode 编号。
目录块当中有多个目录项(或叫目录条目) ,每一个目录项(或目录条目) 都会对应到该目录下的某一个文件,目录项当中记录了该文件的文件名以及它的 inode 节点编号,所以通过目录的目录块便可以遍历找到该目录下的所有文件以及所对应的 inode 节点。
应用程序当中,有时往往需要去获取到一些系统相关的信息,譬如时间、日期、以及其它一些系统相关信息。
地球总是自西向东自转,东边总比西边先看到太阳,东边的时间也总比西边的早。东边时刻与西边时刻的差值不仅要以时计,而且还要以分和秒来计算,这给人们的日常生活和工作都带来许多不便。
GMT(Greenwich Mean Time) 中文全称是格林威治标准时间, 这个时间系统的概念在 1884 年被确立,由英国伦敦的格林威治皇家天文台计算并维护,并在之后的几十年向欧陆其它国家扩散。由于从 19 实际开始,因为世界各国往来频繁,而欧洲大陆、美洲大陆以及亚洲大陆都有各自的时区,所以为了避免时间混乱, 1884 年,各国代表在美国华盛顿召开国际大会,通过协议选出英国伦敦的格林威治作为全球时间的中心点, 决定以通过格林威治的子午线作为划分东西两半球的经线零度线(本初子午线、零度经线) ,由此格林威治标准时间因而诞生!所以 GMT 时间就是英国格林威治当地时间, 也就是零时区(中时区) 所在时间, 譬如 GMT 12:00 就是指英国伦敦的格林威治皇家天文台当地的中午 12:00,与我国的标准时间北京时间(东八区)相差 8 个小时,即早八个小时,所以 GMT 12:00 对应的北京时间是 20:00。
UTC(Coordinated Universal Time)指的是世界协调时间(又称世界标准时间、世界统一时间), 是经过平均太阳时(以格林威治时间 GMT 为准)、地轴运动修正后的新时标以及以「秒」为单位的国际原子时所综合精算而成的时间,计算过程相当严谨精密,因此若以「世界标准时间」的角度来说, UTC 比 GMT 来得更加精准。
GMT 与 UTC 这两者几乎是同一概念,它们都是指格林威治标准时间,也就是国际标准时间,只不过UTC 时间比 GMT 时间更加精准,所以在我们的编程当中不用刻意去区分它们之间的区别。
全球被划分为 24 个时区,每一个时区横跨经度 15 度,以英国格林威治的本初子午线作为零度经线,将全球划分为东西两半球, 分为东一区、东二区、东三区……东十二区以及西一区、西二区、西三区……西十二区,而本初子午线所在时区被称为中时区(或者叫零时区)
东十二区和西十二区其实是一个时区,就是十二区,东十二区与西十二区各横跨经度 7.5 度,以 180 度经线作为分界线。 每个时区的中央经线上的时间就是这个时区内统一采用的时间,称为区时。相邻两个时区的时间相差 1 小时。例如,我国东 8 区的时间总比泰国东 7 区的时间早 1 小时,而比日本东 9 区的时间晚 1小时。因此,出国旅行的人,必须随时调整自己的手表,才能和当地时间相一致。凡向西走,每过一个时区,就要把表向前拨 1 小时(比如 2 点拨到 1 点);凡向东走,每过一个时区,就要把表向后拨 1 小时(比如 1 点拨到 2 点)。
实际上,世界上不少国家和地区都不严格按时区来计算时间。为了在全国范围内采用统一的时间,一般都把某一个时区的时间作为全国统一采用的时间。例如,我国把首都北京所在的东 8 区的时间作为全国统一的时间,称为北京时间, 北京时间就作为我国使用的本地时间, 譬如我们电脑上显示的时间就是北京时间, 我国国土面积广大,由东到西横跨了 5 个时区,也就意味着我国最东边的地区与最西边的地区实际上相差了 4、 5 个小时。 又例如,英国、法国、荷兰和比利时等国,虽地处中时区,但为了和欧洲大多数国家时间相一致,则采用东 1 区的时间。世界标准时间指的就是格林威治时间, 也就是中时区对应的时间, 用格林威治当地时间作为全球统一时间,用以描述全球性的事件,方便大家记忆、以免混淆。
proc 文件系统是一个虚拟文件系统, 它以文件系统的方式为应用层访问系统内核数据提供了接口, 用户和应用程序可以通过 proc 文件系统得到系统信息和进程相关信息,对 proc 文件系统的读写作为与内核进行通信的一种手段。 但是与普通文件不同的是, proc 文件系统是动态创建的,文件本身并不存在于磁盘当中、 只存在于内存当中,与 devfs 一样,都被称为虚拟文件系统。
最初构建 proc 文件系统是为了提供有关系统中进程相关的信息, 但是由于这个文件系统非常有用,因此内核中的很多信息也开始使用它来报告,或启用动态运行时配置。 内核构建 proc 虚拟文件系统,它会将内核运行时的一些关键数据信息以文件的方式呈现在 proc 文件系统下的一些特定文件中,这样相当于将一些不可见的内核中的数据结构以可视化的方式呈现给应用层。
proc 文件系统挂载在系统的/proc 目录下, 对于内核开发者(譬如驱动开发工程师)来说, proc 文件系统给了开发者一种调试内核的方法:通过查看/proc/xxx 文件来获取到内核特定数据结构的值,在添加了新功能前后进行对比,就可以判断此功能所产生的影响是否合理。
/proc 目录下有很多以数字命名的文件夹,譬如 100038、 2299、 98560,这些数字对应的其实就是一个一个的进程 PID 号,每一个进程在内核中都会存在一个编号,通过此编号来区分不同的进程,这个编号就是 PID 号。
/proc 目录下除了文件夹之外,还有很多的虚拟文件,譬如 buddyinfo、 cgroups、 cmdline、 version 等等,不同的文件记录了不同信息, 关于这些文件记录的信息和意思如下:
信号是事件发生时对进程的通知机制,也可以把它称为软件中断。信号与硬件中断的相似之处在于能够打断程序当前执行的正常流程, 其实是在软件层次上对中断机制的一种模拟。 大多数情况下,是无法预测信号达到的准确时间,所以,信号提供了一种处理异步事件的方法。
一个具有合适权限的进程能够向另一个进程发送信号,信号的这一用法可作为一种同步技术,甚至是进程间通信(IPC)的原始形式。 信号可以由“谁”发出呢? 以下列举的很多情况均可以产生信号:
进程同样也可以向自身发送信号,然而发送给进程的诸多信号中,大多数都是来自于内核。
以上便是可以产生信号的多种不同的条件,总的来看,信号的目的都是用于通信的,当发生某种情况下,通过信号将情况“告知”相应的进程,从而达到同步、通信的目的。
信号通常是发送给对应的进程,当信号到达后, 该进程需要做出相应的处理措施,通常进程会视具体信号执行以下操作之一:
信号是异步事件的经典实例,产生信号的事件对进程而言是随机出现的,进程无法预测该事件产生的准确时间,进程不能够通过简单地测试一个变量或使用系统调用来判断是否产生了一个信号,这就如同硬件中断事件,程序是无法得知中断事件产生的具体时间,只有当产生中断事件时,才会告知程序、然后打断当前程序的正常执行流程、跳转去执行中断服务函数,这就是异步处理方式。
信号本质上是 int 类型的数字编号,这就好比硬件中断所对应的中断号。内核针对每个信号,都给其定义了一个唯一的整数编号,从数字 1 开始顺序展开。并且每一个信号都有其对应的名字(其实就是一个宏),信号名字与信号编号乃是一一对应关系,但是由于每个信号的实际编号随着系统的不同可能会不一样,所以在程序当中一般都使用信号的符号名(也就是宏定义)。
不存在编号为 0 的信号,从示例代码 8.1.1 中也可以看到,信号编号是从 1 开始的,事实上 kill()函数对信号编号 0 有着特殊的应用。
信号是否可靠取决于信号是否会进行排队处理,可靠信号支持排队,不会丢失。
实时信号与非实时信号其实是从时间关系上进行的分类,与可靠信号与不可靠信号是相互对应的, 非实时信号都不支持排队,都是不可靠信号;实时信号都支持排队,都是可靠信号。 实时信号保证了发送的多个信号都能被接收, 实时信号是 POSIX 标准的一部分,可用于应用进程。
一般我们也把非实时信号(不可靠信号)称为标准信号,如果文档中用到了这个词,那么大家要知道,这里指的就是非实时信号(不可靠信号)。
内核为每一个进程维护了一个信号掩码(其实就是一个信号集) ,即一组信号。当进程接收到一个属于信号掩码中定义的信号时,该信号将会被阻塞、无法传递给进程进行处理, 那么内核会将其阻塞,直到该信号从信号掩码中移除,内核才会把该信号传递给进程从而得到处理。
操作系统下的应用程序在运行 main()函数之前需要先执行一段引导代码,最终由这段引导代码去调用应用程序的 main()函数,我们在编写应用程序的时候,不用考虑引导代码的问题,在编译链接时,由链接器将引导代码链接到我们的应用程序当中,一起构成最终的可执行文件。当执行应用程序时,在 Linux 下输入可执行文件的相对路径或绝对路径就可以运行该程序,譬如./app或/home/dt/app,还可根据应用程序是否接受传参在执行命令时在后面添加传入的参数信息,譬如./app arg1arg2 或/home/dt/app arg1 arg2。程序运行需要通过操作系统的加载器来实现,加载器是操作系统中的程序,当执行程序时,加载器负责将此应用程序加载内存中去执行。
所以由此可知,对于操作系统下的应用程序来说,链接器和加载器都是很重要的角色!
argc 和 argv 传参是如何实现的呢?譬如./app arg1 arg2,这两个参数 arg1 和 arg2 是如何传递给应用程序的 main 函数的呢? 当在终端执行程序时,命令行参数(command-line argument)由 shell 进程逐一进行解析, shell 进程会将这些参数传递给加载器,加载器加载应用程序时会将其传递给应用程序引导代码,当引导程序调用 main()函数时,在由它最终传递给 main()函数,如此一来,在我们的应用程序当中便可以获取到命令行参数了。
进程其实就是一个可执行程序的实例,这句话如何理解呢?可执行程序就是一个可执行文件,文件是一个静态的概念,存放磁盘中,如果可执行文件没有被运行,那它将不会产生什么作用,当它被运行之后,它将会对系统环境产生一定的影响,所以可执行程序的实例就是可执行文件被运行。进程是一个动态过程,而非静态文件,它是程序的一次运行过程,当应用程序被加载到内存中运行之后它就称为了一个进程,当程序运行结束后也就意味着进程终止,这就是进程的一个生命周期。
Linux 系统下的每一个进程都有一个进程号(process ID,简称 PID),进程号是一个正数,用于唯一标识系统中的某一个进程。执行 ps 命令可以查到系统中进程相关的一些信息,包括每个进程的进程号
每一个进程都有一组与其相关的环境变量, 这些环境变量以字符串形式存储在一个字符串数组列表中,把这个数组称为环境列表。 其中每个字符串都是以“名称=值(name=value)” 形式定义,所以环境变量是“名称-值”的成对集合, 譬如在 shell 终端下可以使用 env 命令查看到 shell 进程的所有环境变量
环境变量常见的用途之一是在 shell 中, 每一个环境变量都有它所表示的含义,譬如 HOME 环境变量表示用户的家目录, USER 环境变量表示当前用户名, SHELL 环境变量表示 shell 解析器名称, PWD 环境变量表示当前所在目录等, 在我们自己的应用程序当中,也可以使用进程的环境变量。
size 命令可以查看二进制可执行文件的文本段、数据段、 bss 段的段大小
在 Linux 系统中,采用了虚拟内存管理技术,事实上大多数现在操作系统都是如此! 在 Linux 系统中,每一个进程都在自己独立的地址空间中运行,在 32 位系统中,每个进程的逻辑地址空间均为 4GB, 这 4GB 的内存空间按照 3:1 的比例进行分配,其中用户进程享有 3G 的空间,而内核独自享有剩下的 1G 空间。
虚拟地址会通过硬件 MMU(内存管理单元)映射到实际的物理地址空间中, 建立虚拟地址到物理地址的映射关系后,对虚拟地址的读写操作实际上就是对物理地址的读写操作, MMU 会将物理地址“翻译”为对应的物理地址。
Linux 系统下,应用程序运行在一个虚拟地址空间中,所以程序中读写的内存地址对应也是虚拟地址,并不是真正的物理地址,譬如应用程序中读写 0x80800000 这个地址,实际上并不对应于硬件的 0x80800000这个物理地址。
计算机物理内存的大小是固定的,就是计算机的实际物理内存, 试想一下,如果操作系统没有虚拟地址机制,所有的应用程序访问的内存地址就是实际的物理地址, 所以要将所有应用程序加载到内存中,但是我们实际的物理内存只有 4G,所以就会出现一些问题:
针对以上的一些问题,就引入了虚拟地址机制, 程序访问存储器所使用的逻辑地址就是虚拟地址,通过逻辑地址映射到真正的物理内存上。 所有应用程序运行在自己的虚拟地址空间中, 使得进程的虚拟地址空间和物理地址空间隔离开来,这样做带来了很多的优点:
在诸多的应用中,创建多个进程是任务分解时行之有效的方法,譬如,某一网络服务器进程可在监听客户端请求的同时,为处理每一个请求事件而创建一个新的子进程,与此同时,服务器进程会继续监听更多的客户端连接请求。在一个大型的应用程序任务中,创建子进程通常会简化应用程序的设计,同时提高了系统的并发性(即同时能够处理更多的任务或请求,多个进程在宏观上实现同时运行)。
父进程调用fork()之后可以创建一个子进程,子进程和父进程会继续执行 fork()调用之后的指令,子进程、父进程各自在自己的进程空间中运行。事实上,子进程是父进程的一个副本, 譬如子进程拷贝了父进程的数据段、堆、栈以及继承了父进程打开的文件描述符,父进程与子进程并不共享这些存储空间,这是子进程对父进程相应部分存储空间的完全复制,执行 fork()之后,每个进程均可修改各自的栈数据以及堆段中的变量,而并不影响另一个进程。
虽然子进程是父进程的一个副本,但是对于程序代码段(文本段)来说, 两个进程执行相同的代码段,因为代码段是只读的, 也就是说父子进程共享代码段,在内存中只存在一份代码段数据。
子进程被创建出来之后,便是一个独立的进程,拥有自己独立的进程空间,系统内唯一的进程号,拥有自己独立的 PCB(进程控制块) ,子进程会被内核同等调度执行,参与到系统的进程调度中
子进程拷贝了父进程的文件描述符表,使得父、子进程中对应的文件描述符指向了相同的文件表, 也意味着父、子进程中对应的文件描述符指向了磁盘中相同的文件,因而这些文件在父、子进程间实现了共享,譬如,如果子进程更新了文件偏移量,那么这个改变也会影响到父进程中相应文件描述符的位置偏移量。
一个进程可以通过 fork()或 vfork()等系统调用创建一个子进程,一个新的进程就此诞生!事实上, Linux系统下的所有进程都是由其父进程创建而来,譬如在 shell 终端通过命令的方式执行一个程序./app,那么 app进程就是由 shell 终端进程创建出来的, shell 终端就是该进程的父进程。既然所有进程都是由其父进程创建出来的,那么总有一个最原始的父进程吧,否则其它进程是怎么创建出来的呢?确实如此,使用"ps -aux"命令可以查看到系统下所有进程信息。
进程号为 1 的进程便是所有进程的父进程,通常称为 init 进程,它是 Linux 系统启动之后运行的第一个进程,它管理着系统上所有其它进程, init 进程是由内核启动,因此理论上说它没有父进程。init 进程的 PID 总是为 1,它是所有子进程的父进程,一切从 1 开始、一切从 init 进程开始!一个进程的生命周期便是从创建开始直至其终止。
通常,进程有两种终止方式:异常终止和正常终止。进程的正常终止有多种不同的方式,譬如在 main 函数中使用 return 返回、调用 exit()函数结束进程、调用_exit()或_Exit()函数结束进程等。调用 exit()函数终止进程时会刷新进程的 stdio 缓冲区,适合父进程退出。异常终止通常也有多种不同的方式,譬如在程序当中调用 abort()函数异常终止进程、当进程接收到某些信号导致异常终止等。
Linux 系统下进程通常存在 6 种不同的状态,分为:就绪态、运行态、僵尸态、 可中断睡眠状态(浅度睡眠)、不可中断睡眠状态(深度睡眠)以及暂停态。
一个新创建的进程会处于就绪态,只要得到 CPU 就能被执行。
进程关系无非就几种:无关系、父子进程关系、同一个进程组关系(默认创建的子进程和父进程一个进程组)、会话。
一个会话可包含一个或多个进程组,但只能有一个前台进程组,其它的是后台进程组;每个会话都有一个会话首领(leader),即创建会话的进程。一个会话可以有控制终端、也可没有控制终端,在有控制终端的情况下也只能连接一个控制终端,这通常是登录到其上的终端设备(在终端登录情况下)或伪终端设备(譬如通过 SSH 协议网络登录), 一个会话中的进程组可被分为一个前台进程组以及一个或多个后台进程组。
会话的首领进程连接一个终端之后,该终端就成为会话的控制终端,与控制终端建立连接的会话首领进程被称为控制进程;产生在终端上的输入和信号将发送给会话的前台进程组中的所有进程,譬如 Ctrl + C(产生 SIGINT 信号)、 Ctrl + Z(产生 SIGTSTP 信号)、 Ctrl + \(产生 SIGQUIT 信号) 等等这些由控制终端产生的信号。当用户在某个终端登录时,一个新的会话就开始了; 当我们在 Linux 系统下打开了多个终端窗口时,实际上就是创建了多个终端会话。
守护进程(Daemon) 也称为精灵进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生, 主要表现为以下两个特点:
守护进程是一种很有用的进程。 Linux 中大多数服务器就是用守护进程实现的,譬如, Internet 服务器inetd、 Web 服务器 httpd 等。同时,守护进程完成许多系统任务,譬如作业规划进程 crond 等。
守护进程 Daemon,通常简称为 d,一般进程名后面带有 d 就表示它是一个守护进程。守护进程与终端无任何关联,用户的登录与注销与守护进程无关、不受其影响,守护进程自成进程组、自成会话,即pid=gid=sid。通过命令"ps -ajx"查看系统所有的进程
TTY 一栏是问号?表示该进程没有控制终端,也就是守护进程,其中 COMMAND 一栏使用中括号[]括起来的表示内核线程,这些线程是在内核里创建,没有用户空间代码,因此没有程序文件名和命令行,通常采用 k 开头的名字,表示 Kernel。
所谓进程间通信指的是系统中两个进程之间的通信,不同的进程都在各自的地址空间中、相互独立、隔离,所以它们是处在于不同的地址空间中,因此相互通信比较难, Linux 内核提供了多种进程间通信的机制。这里向大家介绍 Linux 下提供的进程间通信的手段,用于在多进程的环境下,在一些中小型的程序设计中,多进程的设计其实很少用到,主要用在一些大型项目中,以了解为主,在实际编程中需要用到再去深入学习即可!
进程间通信(interprocess communication,简称 IPC) 指两个进程之间的通信。 系统中的每一个进程都有各自的地址空间,并且相互独立、隔离, 每个进程都处于自己的地址空间中。 所以同一个进程的不同模块(譬如不同的函数)之间进行通信都是很简单的,譬如使用全局变量等。但是,两个不同的进程之间要进行通信通常是比较难的,因为这两个进程处于不同的地址空间中;通常情况下,大部分的程序是不要考虑进程间通信的,因为大家所接触绝大部分程序都是单进程程序(可以有多个线程),对于一些复杂、大型的应用程序,则会根据实际需要将其设计成多进程程序,譬如 GUI、服务区应用程序等。
Linux 内核提供了多种 IPC 机制, 基本是从 UNIX 系统继承而来, 而对 UNIX 发展做出重大贡献的两大主力 AT&T 的贝尔实验室及 BSD(加州大学伯克利分校的伯克利软件发布中心)在进程间通信方面的侧重点有所不同。前者对 UNIX 早期的进程间通信手段进行了系统的改进和扩充,形成了“System V IPC” , 通信进程局限在单个计算机内; 后者则跳过了该限制, 形成了基于套接字(Socket,也就是网络)的进程间通信机制。 Linux 则把两者继承了下来
其中,早期的 UNIX IPC 包括:管道、 FIFO、信号; System V IPC 包括: System V 信号量、 System V消息队列、 System V 共享内存;上图中还出现了 POSIX IPC,事实上, 较早的 System V IPC 存在着一些不足之处, 而 POSIX IPC 则是在 System V IPC 的基础上进行改进所形成的,弥补了 System V IPC 的一些不足之处。 POSIX IPC 包括: POSIX 信号量、 POSIX 消息队列、 POSIX 共享内存。
总结如下:
管道是 UNIX 系统上最古老的 IPC 方法,它在 20 世纪 70 年代早期 UNIX 的第三个版本上就出现了。把一个进程连接到另一个进程的数据流称为管道,管道被抽象成一个文件, 5.1 小节曾提及过管道文件(pipe)这样一种文件类型。
管道包括三种:
普通管道可用于具有亲缘关系的进程间通信,并且数据只能单向传输,如果要实现双向传输,则必须要使用两个管道;而流管道去除了普通管道的第一种限制,可以半双工的方式实现双向传输,但也只能在具有亲缘关系的进程间通信;而有名管道(FIFO)则同时突破了普通管道的两种限制,即可实现双向传输、又能在非亲缘关系的进程间通信。
消息队列是消息的链表, 存放在内核中并由消息队列标识符标识, 消息队列克服了信号传递信息少、 管道只能承载无格式字节流以及缓冲区大小受限等缺陷。 消息队列包括 POSIX 消息队列和 System V 消息队列。
消息队列是 UNIX 下不同进程之间实现共享资源的一种机制, UNIX 允许不同进程将格式化的数据流以消息队列形式发送给任意进程, 有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息
信号量是一个计数器, 与其它进程间通信方式不大相同, 它主要用于控制多个进程间或一个进程内的多个线程间对共享资源的访问, 相当于内存中的标志,进程可以根据它判定是否能够访问某些共享资源,同时,进程也可以修改该标志, 除了用于共享资源的访问控制外,还可用于进程同步。
它常作为一种锁机制, 防止某进程在访问资源时其它进程也访问该资源, 因此, 主要作为进程间以及同一个进程内不同线程之间的同步手段。 Linux 提供了一组精心设计的信号量接口来对信号量进行操作,它们声明在头文件 sys/sem.h 中。
共享内存就是映射一段能被其它进程所访问的内存, 这段共享内存由一个进程创建, 但其它的多个进程都可以访问, 使得多个进程可以访问同一块内存空间。共享内存是最快的 IPC 方式, 它是针对其它进程间通信方式运行效率低而专门设计的, 它往往与其它通信机制, 譬如结合信号量来使用, 以实现进程间的同步和通信。
Socket 是一种 IPC 方法,是基于网络的 IPC 方法,允许位于同一主机(计算机)或使用网络连接起来的不同主机上的应用程序之间交换数据,说白了就是网络通信,在提高篇章节内容中将会向大家介绍 Linux系统下的网络编程。
在一个典型的客户端/服务器场景中,应用程序使用 socket 进行通信的方式如下:
1.各个应用程序创建一个 socket。 socket 是一个允许通信的“设备”,两个应用程序都需要用到它。
2.服务器将自己的 socket 绑定到一个众所周知的地址(名称)上使得客户端能够定位到它的位置。
线程是参与系统调度的最小单位。 它被包含在进程之中, 是进程中的实际运行单位。一个线程指的是进程中一个单一顺序的控制流(或者说是执行路线、执行流), 一个进程中可以创建多个线程, 多个线程实现并发运行, 每个线程执行不同的任务。 譬如某应用程序设计了两个需要并发运行的任务 task1 和 task2,可将两个不同的任务分别放置在两个线程中。就像每个进程都有一个进程 ID 一样,每个线程也有其对应的标识,称为线程 ID。进程 ID 在整个系统中是唯一的,但线程 ID 不同,线程 ID 只有在它所属的进程上下文中才有意义。
当一个程序启动时,就有一个进程被操作系统(OS)创建,与此同时一个线程也立刻运行,该线程通常叫做程序的主线程(Main Thread),因为它是程序一开始时就运行的线程。应用程序都是以 main()做为入口开始运行的,所以 main()函数就是主线程的入口函数, main()函数所执行的任务就是主线程需要执行的任务。
所以由此可知,任何一个进程都包含一个主线程, 只有主线程的进程称为单线程进程,有单线程进程,那自然就存在多线程进程,所谓多线程指的是除了主线程以外, 还包含其它的线程,其它线程通常由主线程来创建( 调用pthread_create 创建一个新的线程) ,那么创建的新线程就是主线程的子线程。
主线程的重要性体现在两方面:
1.其它新的线程(也就是子线程)是由主线程创建的;
2.主线程通常会在最后结束运行, 执行各种清理工作,譬如回收各个子线程。
线程是程序最基本的运行单位,而进程不能运行, 真正运行的是进程中的线程。 当启动应用程序后,系统就创建了一个进程,可以认为进程仅仅是一个容器, 它包含了线程运行所需的数据结构、环境变量等信息。同一进程中的多个线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack,我们称为线程栈),自己的寄存器环境(registercontext) 、 自己的线程本地存储(thread-local storage)。在多线程应用程序中,通常一个进程中包括了多个线程,每个线程都可以参与系统调度、被 CPU 执行,线程具有以下一些特点:
此外,还可以访问进程所拥有的已打开文件、定时器、信号量等等
进程创建多个子进程可以实现并发处理多任务(本质上便是多个单线程进程),多线程同样也可以实现(一个多线程进程) 并发处理多任务的需求,那我们究竟选择哪种处理方式呢? 首先我们就需要来分析下多进程和多线程两种编程模型的优势和劣势。
多进程编程的劣势:
终上所述,多线程编程相比于多进程编程的优势是比较明显的,在实际的应用当中多线程远比多进程应用更为广泛。那既然如此,为何还存在多进程编程模型呢?难道多线程编程就不存在缺点吗?当然不是,多线程也有它的缺点、劣势, 譬如多线程编程难度高,对程序员的编程功底要求比较高,因为在多线程环境下需要考虑很多的问题, 例如线程安全问题、信号处理的问题等, 编写与调试一个多线程程序比单线程程序困难得多。
我先举个简单例子来方便大家理解下面的内容:并行就像是”一心多用“,比如一边吃饭一边看电视,游刃有余。而并发呢,就是吃饭的时候有人来敲门你就得去开门,然后有和客人寒酸几句然后又吃饭然后水烧开了又去关水然后又回来吃饭。这样下去你做了很多事情,但是每次你都只是在做一件事情。只要并发发生得够快,就相当于你同时做了很多件事情了。
对于多核处理器系统来说, 它拥有多个执行单元, 在操作系统中,多个执行单元以并行方式运行多个线程,同时每一个执行单元以并发方式运行系统中的多个线程。
计算机处理器运行速度是非常快的,在单个处理核心虽然以并发方式运行着系统中的线程(微观上交替/交叉方式运行不同的线程) , 但在宏观上所表现出来的效果是同时运行着系统中的所有线程,因为处理器的运算速度太快了,交替轮训一次所花费的时间在宏观上几乎是可以忽略不计的,所以表示出来的效果就是同时运行着所有线程。这就好比现实生活中所看到的一些事情,它所给带来的视角效果,譬如一辆车在高速上行驶,有时你会感觉到车的轮毂没有转动, 一种视角暂留现象,因为车轮转动速度太快了,人眼是看不清的,会感觉车轮好像是静止的,事实上,车轮肯定是在转动着。
对于串行比较容易理解,它指的是一种顺序执行,譬如先完成 task1,接着做 task2、直到完成 task2,然后做 task3、直到完成 task3……依次按照顺序完成每一件事情,必须要完成上一件事才能去做下一件事,只有一个执行单元,这就是串行运行。
并行与串行则截然不同,并行指的是可以并排/并列执行多个任务, 这样的系统,它通常有多个执行单元, 所以可以实现并行运行,譬如并行运行 task1、 task2、 task3。
并行运行并不一定要同时开始运行、同时结束运行,只需满足在某一个时间段上存在多个任务被多个执行单元同时在运行着
相比于串行和并行,并发强调的是一种时分复用,与串行的区别在于,它不必等待上一个任务完成之后在做下一个任务,可以打断当前执行的任务切换执行下一个任何,这就是时分复用。 在同一个执行单元上,将时间分解成不同的片段(时间片),每个任务执行一段时间,时间一到则切换执行下一个任务,依次这样轮训(交叉/交替执行) ,这就是并发运行。
需要注意的是,并行运行情况下的多个执行单元,每一个执行单元同样也可以以并发方式运行。从通用角度上介绍完这三个概念之后, 类比到计算机系统中, 首先我们需要知道两个前提条件:
1.多核处理器和单核处理器:对于单核处理器来说,只有一个执行单元,同时只能执行一条指令;而对于多核处理起来说,有多个执行单元,可以并行执行多条指令,譬如 8 核处理器,那么可以并行执行 8 条不同的指令。
2.计算机操作系统中,通常同时运行着几十上百个不同的线程,在单核或多核处理系统中都是如此!
对于单核处理器系统来说,它只有一个执行单元,只能采用并发运行系统中的线程, 而肯定不可能是串行, 而事实上确实如此。内核实现了调度算法,用于控制系统中所有线程的调度,简单点来说,系统中所有参与调度的线程会加入到系统的调度队列中,它们由内核控制,每一个线程执行一段时间后,由系统调度切换执行调度队列中下一个线程,依次进行。
线程的主要优势在于,资源的共享性,譬如通过全局变量来实现信息共享,不过这种便捷的共享是有代价的,那就是多个线程并发访问共享数据所导致的数据不一致的问题。
如图所示,当线程a写到一半的时候数据被读取了,等到a写完之后线程a与b的读取数据就不一样了。
解决方法:
只要在线程a进行操作的相应部分锁起来就可以了,于是就触及到互斥锁、条件变量、自旋锁以及读写锁等。
互斥锁(mutex)又叫互斥量,从本质上说是一把锁,在访问共享资源之前对互斥锁进行上锁,在访问完成后释放互斥锁(解锁);对互斥锁进行上锁之后,任何其它试图再次对互斥锁进行加锁的线程都会被阻塞,直到当前线程释放互斥锁。如果释放互斥锁时有一个以上的线程阻塞,那么这些阻塞的线程会被唤醒,它们都会尝试对互斥锁进行加锁,当有一个线程成功对互斥锁上锁之后,其它线程就不能再次上锁了,只能再次陷入阻塞,等待下一次解锁。
在我们的程序设计当中,只有将所有线程访问共享资源都设计成相同的数据访问规则,互斥锁才能正常工作。如果允许其中的某个线程在没有得到锁的情况下也可以访问共享资源,那么即使其它的线程在使用共享资源前都申请锁,也还是会出现数据不一致的问题。
条件变量是线程可用的另一种同步机制。条件变量用于自动阻塞线程,知道某个特定事件发生或某个条件满足为止,通常情况下,条件变量是和互斥锁一起搭配使用的。 使用条件变量主要包括两个动作:
1.一个线程等待某个条件满足而被阻塞;
2.另一个线程中,条件满足时发出“信号”。
条件变量通常搭配互斥锁来使用, 是因为条件的检测是在互斥锁的保护下进行的, 也就是说条件本身是由互斥锁保护的, 线程在改变条件状态之前必须首先锁住互斥锁, 不然就可能引发线程不安全的问题。
自旋锁与互斥锁很相似, 从本质上说也是一把锁,在访问共享资源之前对自旋锁进行上锁,在访问完成后释放自旋锁(解锁);事实上,从实现方式上来说,互斥锁是基于自旋锁来实现的,所以自旋锁相较于互斥锁更加底层。
如果在获取自旋锁时, 自旋锁处于未锁定状态, 那么将立即获得锁(对自旋锁上锁); 如果在获取自旋锁时,自旋锁已经处于锁定状态了,那么获取锁操作将会在原地“自旋”, 直到该自旋锁的持有者释放了锁。由此介绍可知,自旋锁与互斥锁相似, 但是互斥锁在无法获取到锁时会让线程陷入阻塞等待状态;而自旋锁在无法获取到锁时, 将会在原地“自旋”等待。 “自旋” 其实就是调用者一直在循环查看该自旋锁的持有者是否已经释放了锁,“自旋”一词因此得名。
自旋锁的不足之处在于:自旋锁一直占用的 CPU,它在未获得锁的情况下,一直处于运行状态(自旋),所以占着 CPU,如果不能在很短的时间内获取锁,这无疑会使 CPU 效率降低。试图对同一自旋锁加锁两次必然会导致死锁,而试图对同一互斥锁加锁两次不一定会导致死锁,原因在于互斥锁有不同的类型,当设置为PTHREAD_MUTEX_ERRORCHECK 类型时,会进行错误检查, 第二次加锁会返回错误, 所以不会进入死锁状态。因此我们要谨慎使用自旋锁,自旋锁通常用于以下情况: 需要保护的代码段执行时间很短,这样就会使得持有锁的线程会很快释放锁,而“自旋”等待的线程也只需等待很短的时间;在这种情况下就比较适合使用自旋锁,效率高!
综上所述,再来总结下自旋锁与互斥锁之间的区别:
互斥锁或自旋锁要么是加锁状态、要么是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁有3 种状态:读模式下的加锁状态(以下简称读加锁状态)、写模式下的加锁状态(以下简称写加锁状态)和不加锁状态(见) ,一次只有一个线程可以占有写模式的读写锁,但是可以有多个线程同时占有读模式的读写锁。因此可知,读写锁比互斥锁具有更高的并行性!
读写锁有如下两个规则:
虽然各操作系统对读写锁的实现各不相同,但当读写锁处于读模式加锁状态,而这时有一个线程试图以写模式获取锁时, 该线程会被阻塞;而如果另一线程以读模式获取锁,则会成功获取到锁,对共享资源进行读操作。
所以,读写锁非常适合于对共享数据读的次数远大于写的次数的情况。当读写锁处于写模式加锁状态时,它所保护的数据可以被安全的修改,因为一次只有一个线程可以在写模式下拥有这个锁;当读写锁处于读模式加锁状态时,它所保护的数据就可以被多个获取读模式锁的线程读取。 所以在应用程序当中,使用读写锁实现线程同步, 当线程需要对共享数据进行读操作时,需要先获取读模式锁(对读模式锁进行加锁),当读取操作完成之后再释放读模式锁(对读模式锁进行解锁);当线程需要对共享数据进行写操作时,需要先获取到写模式锁,当写操作完成之后再释放写模式锁。
读写锁也叫做共享互斥锁。当读写锁是读模式锁住时,就可以说成是共享模式锁住。当它是写模式锁住时,就可以说成是互斥模式锁住。
阻塞式 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 的优点在于能够提升 CPU 的处理效率,当自身条件不满足时,进入阻塞状态,交出 CPU资源,将 CPU 资源让给别人使用;而非阻塞式则是抓紧利用 CPU 资源,譬如不断地去轮训, 这样就会导致该程序占用了非常高的 CPU 使用率!
阻塞式 I/O 存在一个困境,无法实现并发读取(同时读取)。比如说我需要同时读取a与b的数据,当调用函数读取a时,没有数据产生,那么程序就会被马上挂起,这时b就没有被读取过。当然大家可能会想到使用多线程,一个线程读取a、另一个线程读取b,亦或者创建一个子进程,父进程读取a、子进程读取b等方法。
I/O 多路复用(IO multiplexing) 它通过一种机制,可以监视多个文件描述符,一旦某个文件描述符(也就是某个文件) 可以执行 I/O 操作时, 能够通知应用程序进行相应的读写操作。 I/O 多路复用技术是为了解决:在并发式 I/O 场景中进程或线程阻塞到某个 I/O 系统调用而出现的技术,使进程不阻塞于某个特定的I/O 系统调用。
由此可知, I/O 多路复用一般用于并发式的非阻塞 I/O,也就是多路非阻塞 I/O,譬如程序中既要读取a、又要读取b,多路读取。
我们可以采用两个功能几乎相同的系统调用来执行 I/O 多路复用操作,分别是系统调用 select()和 poll()。这两个函数基本是一样的,细节特征上存在些许差别!
I/O 多路复用存在一个非常明显的特征:外部阻塞式, 内部监视多路 I/O。
I/O 多路复用中,进程通过系统调用 select()或 poll()来主动查询文件描述符上是否可以执行 I/O 操作。而在异步 I/O 中,当文件描述符上可以执行 I/O 操作时,进程可以请求内核为自己发送一个信号。 之后进程就可以执行任何其它的任务直到文件描述符可以执行 I/O 操作为止,此时内核会发送信号给进程。所以要使用异步 I/O,还得结合前面所学习的信号相关的内容,所以异步 I/O 通常也称为信号驱动 I/O。
要使用异步 I/O,程序需要按照如下步骤来执行:
在一个需要同时检查大量文件描述符(譬如数千个)的应用程序中,例如某种类型的网络服务端程序,异步 I/O 能够提供显著的性能优势。之所以如此,原因在于:对于异步 I/O,内核可以“记住”要检查的文件描述符,且仅当这些文件描述符上可执行 I/O 操作时,内核才会向应用程序发送信号。
存储映射 I/O(memory-mapped I/O) 是一种基于内存区域的高级 I/O 操作,它能将一个文件映射到进程地址空间中的一块内存区域中, 当从这段内存中读数据时,就相当于读文件中的数据(对文件进行 read 操作) ,将数据写入这段内存时,则相当于将数据直接写入文件中(对文件进行 write 操作) 。这样就可以在不使用基本 I/O 操作函数 read()和 write()的情况下执行 I/O 操作。
普通 I/O 方式一般是通过调用 read()和 write()函数来实现对文件的读写, 使用 read()和 write()读写文件时,函数经过层层的调用后,才能够最终操作到文件,中间涉及到很多的函数调用过程,数据需要在不同的缓存间倒腾,效率会比较低。 同样使用标准 I/O(库函数 fread()、 fwrite())也是如此,本身标准 I/O 就是对普通 I/O 的一种封装。
那既然效率较低,为啥还要使用这种方式呢?原因在于,只有当数据量比较大时,效率的影响才会比较明显,如果数据量比较小,影响并不大,使用普通的 I/O 方式还是非常方便的。
存储映射 I/O 的实质其实是共享, 与 IPC 之内存共享很相似。譬如执行一个文件复制操作来说,对于普通 I/O 方式,首先需要将源文件中的数据读取出来存放在一个应用层缓冲区中,接着再将缓冲区中的数据写入到目标文件中
而对于存储映射 I/O 来说,由于源文件和目标文件都已映射到了应用层的内存区域中,所以直接操作映射区来实现文件复制
首先非常直观的一点就是,使用存储映射 I/O 减少了数据的复制操作, 所以在效率上会比普通 I/O 要高,其次上面也讲了,普通 I/O 中间涉及到了很多的函数调用过程,这些都会导致普通 I/O 在效率上会比存储映射 I/O 要低。
前面提到存储映射 I/O 的实质其实是共享,如何理解共享呢?其实非常简单, 我们知道,应用层与内核层是不能直接进行交互的,必须要通过操作系统提供的系统调用或库函数来与内核进行数据交互,包括操作硬件。通过存储映射 I/O 将文件直接映射到应用程序地址空间中的一块内存区域中,也就是映射区;直接将磁盘文件直接与映射区关联起来,不用调用 read()、 write()系统调用,直接对映射区进行读写操作即可操作磁盘上的文件,而磁盘文件中的数据也可反应到映射区中,这就是一种共享,可以认为映射区就是应用层与内核层之间的共享内存。
存储映射 I/O 方式并不是完美的,它所映射的文件只能是固定大小。另外,文件映射的内存区域的大小必须是系统页大小的整数倍,譬如映射文件的大小为 96 字节,假定系统页大小为 4096 字节,那么剩余的 4000 字节全部填充为 0,虽然可以通过映射地址访问剩余的这些字节数据,但不能在映射文件中反应出来,由此可知,使用存储映射 I/O 在进行大数据量操作时比较有效;对于少量数据,使用普通 I/O 方式更加方便!存储映射 I/O 在处理大量数据时效率高,对于少量数据处理不是很划算,所以通常来说,存储映射 I/O 会在视频图像处理方面用的比较多。
当两个人同时编辑磁盘中同一份文件时,其后果将会如何呢?在 Linux 系统中,该文件的最后状态通常取决于写该文件的最后一个进程。 多个进程同时操作同一文件,很容易导致文件中的数据发生混乱,因为多个进程对文件进行 I/O 操作时,容易产生竞争状态、导致文件中的内容与预想的不一致!
对于有些应用程序,进程有时需要确保只有它自己能够对某一文件进行 I/O 操作,在这段时间内不允许其它进程对该文件进行 I/O 操作。为了向进程提供这种功能, Linux 系统提供了文件锁机制。
前面学习过互斥锁、自旋锁以及读写锁,文件锁与这些锁一样,都是内核提供的锁机制, 锁机制实现用于对共享资源的访问进行保护; 只不过互斥锁、自旋锁、 读写锁与文件锁的应用场景不一样, 互斥锁、自旋锁、读写锁主要用在多线程环境下,对共享资源的访问进行保护, 做到线程同步。
而文件锁, 顾名思义是一种应用于文件的锁机制, 当多个进程同时操作同一文件时,我们怎么保证文件数据的正确性, linux 通常采用的方法是对文件上锁, 来避免多个进程同时操作同一文件时产生竞争状态。
譬如进程对文件进行 I/O 操作时,首先对文件进行上锁,将其锁住,然后再进行读写操作;只要进程没有对文件进行解锁,那么其它的进程将无法对其进行操作;这样就可以保证,文件被锁住期间,只有它(该进程)可以对其进行读写操作。
一个文件既然可以被多个进程同时操作,那说明文件必然是一种共享资源,所以由此可知,归根结底,文件锁也是一种用于对共享资源的访问进行保护的机制,通过对文件上锁, 来避免访问共享资源产生竞争状态。
文件锁可以分为建议性锁和强制性锁两种:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。