60801柱面的硬盘的柱面扇面1个柱面多少mb

第 1 章 你必须知道的!Linux内部结构
第 1 章 你必须知道的!Linux内部结构
1.1 Linux的三大基础:磁盘、进程、内存
Linux 工程师的工作有时需要登录陌生的 Linux 服务器才能进行。在这种时候,起初笔者会用到 df、ps 和 free 这三个命令,倒并非出于刻意,只是本能地去执行这些指令。
例如,当你接到一个紧急事件的请求响应,而事先又不知道该服务器的配置信息时,就得首先用这三个命令检查一下这台服务器的状态,并对当前资源的使用情况进行确认。1
1此外,通过 w 命令来确认是否有用户在同一时间在同一台服务器上工作,也是非常重要的。
首先,df 命令主要用来检查文件系统上的可用空间。虽然服务器在运行过程中通常会对文件系统的使用率进行监测,但在进行设备维护而暂时停止监测时,就很容易导致意外情况的发生。
比如曾发生过这样的严重状况:当被告知“某个命令无法顺利执行”时,试着输入 df 指令,竟发现此时根文件系统的利用率已高达 100%。因此,在登录服务器进行工作时,还是养成时常用 df 命令来检查文件系统使用率的习惯吧。
df 命令也可用于确认磁盘分区的构成以及数据的分配状况,还可以确认用于保存数据的文件系统有多少千兆字节,或是否有 NFS 安装区域等。
了解应用系统的使用数据是如何被组织和存储的,是全面了解服务器运行的一个关键。经典的软件开发著作《人月神话》[1] 中写道:“光看流程图不看数据表只是徒劳,但看了数据表,流程图便不看也罢。”IT,顾名思义,是指信息技术,其核心自然是信息(数据)的处理,因此数据可以说是所有操作的基本了。
此外,服务器是利用进程来处理数据的,而 ps 命令则可以被用来确认当前服务器上运行进程的状态。
如果平常我们能接触到各种 ps 命令的输出,那么只需看看进程的名称,就可以大致了解这台服务器的用途和设置。有时会发现一些意料之外的进程正在运行,而经过仔细确认,可能就会在不经意间找到问题的原因。
最后的 free 命令适用于确认存储的使用状况。通过 ps 命令所得到的进程的信息,再结合磁盘、高速缓存和内存的使用情况,能很好地掌握服务器的运行状态,了解这是一个“重载”到什么程度的服务器。
Technical Notes
[1] 《人月神话》弗雷德里克·布鲁克斯(著),汪颖(译),清华大学出版社,2007
所谓的“重载”“轻载”,是一种含糊的说法,实际上是结合过去 CPU 的使用率和磁盘 I/O 的频率来进行综合判断的。然而,CPU 或 I/O 操作与内存的使用情况是密切相关的。如同医生可以从患者的外部情况推断病情一样,通过内存的使用情况,就能推断出包括 CPU 和 I/O 在内的整个服务器的运行状态。
即便在 CPU 的使用率和磁盘 I/O 的频率的历史数据十分翔实的情况下,也应时刻质疑“莫非是 CPU 使用率太高了?”“也许是 I/O 负载太高了?”,以这样的态度去观察数据,方能分析出最接近真实状况的结果。
虽然这个引言稍嫌冗长,但通过以上例子,可以总结出三个应了解的 Linux 的基本分支,即用于保存数据的磁盘、实际处理数据的进程,以及存储服务器的各种运行信息的内存。
针对以上三点,本章将从 Linux 内部结构的角度出发,介绍有助于实践应用的知识。
1.2 磁盘和文件
1.2.1 磁盘的 3D 参数
Linux 这一类操作系统的任务是隐藏物理硬件信息,即对用户和上层应用屏蔽底层硬件的差异,并提供统一的操作方法。
但是,对于磁盘装置,还是需要让人知道它的物理构造的。这一点似乎常常被误导。例如,在笔者的 Red Hat Enterprise Linux 6(RHEL6)测试机上运行 fdisk 指令,就能得到如下结果。
# fdisk -l /dev/sda
磁盘 /dev/sda: 500.1 GB,
磁头 255, 扇区 63, 柱面 60801
Units = 柱面数 of 16065 * 512 = 8225280 字节
扇区大小 (逻辑 / 物理): 512 字节 / 512 字节
I/O size (minimum/optimal): 512 bytes / 512 bytes
磁盘标识符: 0x8c403069
第1分区没有在柱面边界结束。
Linux 交换 / Solaris
屏幕上出现了警告信息:“第 1 分区没有在柱面边界结束”。于是在 Web 上检索“磁盘的柱面”,即得到图 1.1 这样的说明图。
图 1.1 典型的硬盘的说明图
结合之前输出结果的第 2 行信息“磁头 255,扇区 63,柱面 60801”,此图该如何解释呢?那就是,这台测试机器的硬盘“有 255 个磁头数,每个磁道有 63 个扇区,一个磁盘可以分割出 60801 个磁道”。
当然,物理上拥有 255 个磁头的磁盘驱动器是不存在的。磁盘装置的柱面数(cylinder)、磁头(head)、扇区(sector)信息,三者统称为 CHS 或“3D 参数”,然而实际上,fdisk 命令表示的 3D 参数信息和磁盘装置的实际构造并没有什么直接的关系。
要弄清楚这是为什么,就需要理解硬盘的两种不同类型(CHS 方式和 LBA 方式)的存取方法(也经常被说成“寻址模式”)以及它们之间的差异。
正如图 1.1 中所示,要特别规定数据读取和写入的扇区(物理磁盘存取的最小单位)所处的位置,只需指定以下三个数值即可:柱面数(从最外缘开始计算第几条磁道)、磁头数(磁盘表面的读 / 写头的个数)以及扇区数(磁道内等分弧段的个数)。
使用时间超过 10 年的旧式磁盘中,磁盘装置的这三个值(CHS 值)指明了读写数据的位置,这就是 CHS 方式。但实际上,Linux 设备驱动程序会计算 CHS 值,以进行磁盘装置与数据之间的读写,因此用户是不需要知道它们的具体数值的。
在磁盘仍然使用 CHS 方式的时代,唯一一次需要用户在操作时知道 CHS 值的,是在创建磁盘分区的时候。根据当时的 MS-DOS 方式,磁盘分区必须以扇区为单位进行操作。
由于柱面是从磁盘的外缘开始按顺序进行编号的,因而磁盘柱面的分割与编号大致如图 1.2 所示。过去还曾有人异想天开,想在不同的物理磁盘(磁盘面)上采用不同的分割方式,但遗憾的是,这样的分区方式是无法实现的。
图 1.2 典型的分区的分割方法
虽然 Linux 没有必要遵循 MS-DOS 方式,但在当时,通过将操作系统引入不同的分区,可以实现 MS-DOS 和 Linux 的多重引导或是 Windows 和 Linux 的多重引导。因此,和 MS-DOS 或 Windows 遵循共同的方式是有实际意义的。
如此一来,和 MS-DOS 中的 fdisk.exe 一样,Linux 中的 fdisk 命令在做磁盘分区时,也得用柱面数来指定分区的开始位置和结束位置了。
然而,这种有着悠久历史的旧式硬盘早已被时代淘汰,沦为“计算机历史博物馆”中的陈列品了(这种说法可能有些夸张,但至少在笔者的家中是难觅其踪了)。
现在的硬盘普遍采用 LBA(Logic Block Addressing,逻辑块寻址)方式进行数据存取。这种方式的机制极其简单,即硬盘内所有的扇区均从 0 开始进行编号(扇区编号),通过扇区数来指定扇区的位置。
扇区号与物理扇区位置之间的对应,是由内置的硬盘控制器来计算的。通常来说,扇区号越小,其对应的物理扇区就越位于磁盘的外侧。
与此相结合,分区的开始位置和结束位置同样也是由扇区号来指定的。之前的 fdisk 命令的输出中,是按照传统的 CHS 方式,使用柱面号来表示各分区的起点和终点的,但实际上这并不是真实的信息。真正的分区信息,需要在 fdisk 命令之后加上选项 -u 来获取。
# fdisk -lu /dev/sda
磁盘 /dev/sda: 500.1 GB,
磁头 255, 扇区 63, 柱面 60801, 合计
Units = 扇区数 of 1 * 512 = 512 字节
扇区大小 (逻辑 / 物理): 512 字节 / 512 字节
I/O size (minimum/optimal): 512 bytes / 512 bytes
磁盘标识符: 0x8c403069
第1分区没有在柱面边界结束。
Linux 交换 / Solaris
如图所示,各个分区的起点和终点是由扇区号来表示的。这些扇区号所划分出的范围就是实际的分区。图中第一个分区是从 2048 号扇区开始的,由此可知 0~2047 扇区是保留扇区,它们不被用作分区。
磁盘的开始部分为主引导扇区 MBR,GRUB stage1.5 就保存在 MBR 之后的空间中。这里的情形是,第 0 号扇区为主引导扇区,stage 1.5 则存储在主引导扇区后直至第 2047 号扇区之间的空间中 2。
2第 1 分区的起始位置,以前的标准是第 63 扇区,最近变更为第 2048 扇区。而 stage1.5 并没有那么大,因此在之前的起始位置保存 stage1.5 也没有问题。
绕了这么大一个圈,下面就开始解释上文所提到的警告信息吧。
由于现在的硬盘是以 LBA 方式存取的,因此分区的开始和结束位置皆通过扇区号来指定。虽然这样做一点问题都没有,但是 fdisk 命令为了支持旧式 CHS 方式的磁盘,仍然以 CHS 方式来表示磁盘信息。
这时,对于 LBA 方式的磁盘,需要转换成其对应的 3D(磁头数、扇区数、柱面数)参数。当分区的结束位置不能用 3D 参数中合适的柱面结束位置来对应表示时,就会出现诸如“第 1 分区没有在柱面边界结束”这样的警告信息。
总之,这种警告信息的出现,意味着“操作系统正以柱面为单位对磁盘进行分区,可能会导致一些问题”。不过对于在 Linux 下使用的磁盘,这种顾虑是多余的。
现在,在 fdisk 命令之后加上选项 -u,便可以通过指定扇区号来指定分区。因此,今后还是养成在 fdisk 命令后附加选项 -u 的习惯吧 3。
3在 Red Hat Enterprise Linux 的高级开发版本 Fedora 所包含的 fdisk 命令中,默认的操作是以扇区为单位的。当需要进行旧式的以柱面为单位的操作时,需要指定选项 -u=cylinders。
1.2.2 新旧分区表
前一节介绍的表示分区开始和结束位置的信息,它们究竟会被写入到磁盘的哪里呢?这是一个常常被问到的问题,答案毫无疑问是“分区表”。
准确地说,分区表就是存放在第 0 号扇区 MBR 的 446~509 字节的部分。在 MBR 的 0~445 字节中,存放的是所谓的引导加载程序,即服务器启动时,用于引导 BIOS 的加电自检以及 GRUB stage1 的加载。
由于一个扇区的大小是 512 字节,这里就会有 510~511(从 0 字节开始,到 511 字节结束)两个字节的剩余,于是按惯例这里的数值就记录为 0xAA55。若磁盘此处的值不为 0xAA55,则判断该磁盘的 MBR 已损坏。
由于分区表的大小只有 64 个字节,因此大部分信息不能被写入其中。在每个分区表中,只记录着“用 CHS 方式描述的分区开始位置和结束位置”以及“用 LBA 方式(扇区号)描述的分区开始位置以及包含的扇区数”这类有代表性的信息(结束位置不用扇区号来记录,而是通过开始位置与扇区数相加计算得到)。
之所以要通过两种方式记录分区开始和结束位置的信息,是有其历史原因的。LBA 方式的磁盘实际上是不使用 CHS 方式记录分区信息的。
图 1.3 是通过 hexdump 命令输出的 MBR 第 446 字节开始往后 66 字节的内容(分区表加最后两个字节)。通过设置详细的选项,输出了以十进制形式表示的 LBA 方式的分区信息。
图 1.3 分区表的转储输出
方框里的 4 行数据,分别是 4 个分区的信息。将它与之前带选项 -u 的 fdisk 命令的输出相比较,可以确定采用 LBA 方式记录的扇区号的信息与之前的信息是一致的。
CHS 方式描述的数值在此不做详细分析,但从分区的开始位置和结束位置出现了若干个相同的数值可以看出,这是段没有意义的信息。由于 CHS 方式描述的信息实际上并不会被投入使用,因此问题不大,但还是应当注意避免混淆。
顺带一提,最后两个字节正是前面介绍的 0xAA55,而把图 1.3 的最后看成 0x55AA 的读者,还请自行学习一下“小端”(little endian)的知识。
如此看来,使用 LBA 方式是最为简单便捷的了。不过,近来这种方式也出现了它的局限性。那么是什么呢?
如图 1.3 所示,虽然采用十进制比较难懂,但能看出表示开始位置的扇区号和全体扇区数的数字总共是 4 个字节,因此可以表示的范围仅为 0xxFFFFFFFF。换句话说,它无法支持扇区数超过 0xFFFFFFFF 的大容量磁盘。
可以想象出这个容量是多大吗?我们知道一个扇区是 512 个字节,用十六进制的计算器进行计算,答案应该是 2TB。
也许有人要问,如果没有十六进制的计算器该怎么办?即便没有这样的计算器,也可以在 Linux 上通过使用 bc 指令来计算,如下所示。ibase=16 即指定“输入值为十六进制”。另外这里提醒大家一下,0xFF 表示的是十进制数 256。
# echo "ibase=16;FFFFFFFF*FF*2" | bc
因此,MBR 中的分区表是有限度的,对于容量大于 2TB 的硬盘,是无法为之创建分区的。
当使用外部存储装置 LUN(逻辑磁盘)作为数据存储区域时,则无需对 LUN 进行分区,只需将其格式化后挂载到文件系统中,或使用 LVM(逻辑卷管理)方法将其作为逻辑卷进行管理等。这样,容量大于 2TB 的 LUN 就也能够使用了。
不过,近来的服务器磁盘正逐步趋向大容量化,随着容量大于 2TB 的本地磁盘的普及,找到容量大于 2TB 的磁盘的分区方法也指日可待。
GPT(GUID Partition Table,GUID 分区表)正是为了解决这个问题应运而生的。现在,如果需要从使用 GPT 的硬盘中启动操作系统,就需要服务器和操作系统都能支持 UEFI。操作系统中,目前的 Red Hat Enterprise Linux 6(RHEL6)是能支持 UEFI 的。
此外,虽然与分区表并不直接相关,但在最近的大容量磁盘中,有的已经以 4KB 作为一个扇区的大小了。
接下来就对 UEFI 和 GPT,以及 4KB 扇区的磁盘做一个详细的介绍,内容或许略微复杂,请大家认真学习。
UEFI 是以后将要取代 BIOS 的一个方案。
我们都知道,当服务器接上电源,系统 BIOS 就开始启动。BIOS 的启动只允许使用 1M 的内存空间。因此系统 BIOS 的设置界面不是图形界面,而是非常简单的基于文本形式的界面。而且服务器上搭载的各种设备的设置不是通过 BIOS 的设置界面来操作的,而是需要通过 Ctrl+A 等按键来单独操作设置界面。这些都是因为 BIOS 所能使用的内存空间有限。
UEFI 打破了 BIOS 的这些限制,在功能方面进行了多种扩展,于是支持 UEFI 的服务器在启动时,就可以直接通过 UEFI 的设置界面来调用各种设备,有的服务器甚至已经有了图形化的设置界面。
此外,调用引导加载程序的方法也发生了改变。以前都需要像 GRUB stage1、stage1.5、stage2 这样分阶段启动引导加载程序,而改进后,在以 GPT 方式创建的“EFI 系统分区”中,引导加载程序存储就可以直接被调用了。
最后介绍一下 GPT。过去的分区表在第 0 扇区 MBR 里,GPT 则被写入第 1 扇区至第 33 扇区中,成为一种新型的分区表(图 1.4)。表 1.1 中总结了这两种分区方式的主要区别。
图 1.4 GPT 的构造
表 1.1 以前的分区和 GPT 的比较
以前的分区GPT
分区表的位置MBRMBR之后(磁盘末尾亦有副本)
最大磁盘容量2TB8ZB(无限)
最大分区数15(使用SCSI磁盘时)1284
分区卷标分区ID(表示用途的ID)GUID(表示用途的ID+唯一的ID)
制作分区的工具fdiskparted
4在设计 GPT 时,通过改变 GPT 头的设置,也可以创建出超过 128 个分区。但一般情况下,128 个仍是最大限度。
为了降低分区表损坏的风险,GPT 在硬盘的最后保存了一份同样内容的分区表副本。GPT 的头部,则记录了可以用作分区的扇区范围。
每个分区的信息都记录在“分区表”中。一个分区表是 128 字节,一个扇区(512 字节)可以记录 4 个分区的信息。每个分区的开始扇区和结束扇区都分别用 8 个字节来记录,因此即便是容量大于 2TB 的硬盘的扇区数,处理起来也是绰绰有余的。
每个分区中都记录着一个特定的 GUID 标签。尤其是用来存储引导装载程序的分区,会附上“EFI 系统分区”(ESP)的标签。
如果是 RHEL6,/boot/efi 下挂载的文件系统就是 ESP 类型的分区。这里保存了类似于 GRUB stage2(grub.efi)的启动过程。支持 UEFI 的服务器,会根据 GUID 定位 ESP,启动其中的 grub.efi5,因此就不再需要 GRUB stage1 和 stage1.5 了。ESP 采用的是 VFAT 格式。
当创建一个 GPT 格式的分区时,应使用 parted 命令。表 1.2 描述了 parted 命令的主要内部命令,它们的具体使用方法可以在网上查到,此处不再赘述。
表 1.2 parted 命令的主要内部命令
check对文件系统进行检查
cp复制分区
help显示对相关命令的说明。“help”可以显示每一条命令的详细信息
mkfs创建文件系统
mklabel指定表示分区表类型的磁盘标签。在使用GPT时即指定“gpt”
mkpart创建分区
mkpartfs进行分区和文件系统的创建
move移动分区
print显示当前的分区表或磁盘标签的状态
quit结束parted命令
resize改变分区的大fi
rm删除分区
select指定要处理的设备(例如:/dev/sda)
set设置包括引导标志在内的各种标志
由于 RHEL6 也支持 GPT,因此在容量大于 2TB 的硬盘上进行安装时,会自动采用 GPT 方式进行分区。
Technical Notes
[2] 使用 RHEL6 Rescue 模式的备份指南(非 LVM 环境 /NFS 环境 /uEFI 模式版)6
4KB扇区的磁盘
以前的传统硬盘,一个扇区固定为 512 字节。而对于大容量硬盘,则通过增加扇区的大小,来减小访问扇区产生的消耗。
另外,硬盘内部记录了每个扇区错误校验所需要的信息。通过增加扇区的大小,可以降低这些附加信息所占的百分比,使记录数据的空间得到更加有效的利用。
但是,由于访问硬盘的服务器硬件或操作系统(设备驱动)都是按照之前 512 字节的扇区大小设计的,因此不能一味单纯地增加硬盘扇区的大小。
为了实现两者的兼容,就产生了通过硬盘中的控制器来从逻辑上模拟 512 字节扇区的运作方式。如图 1.5 所示,从服务器的角度来看,扇区的大小仍然是 512 字节,但实际的数据读取和写入是在 4KB 扇区上进行的。
最近有不少新面世的硬盘均采用了这种处理方式。
图 1.5 4KB 扇区的磁盘结构
乍一看,这种转换方式会产生一定的开销。在读取 4KB 扇区中的逻辑扇区数据时,即便仅仅 512 个字节的读取和写入,也会需要对整个 4KB 扇区进行操作。但是实际上,若是大数据的读取和写入是从 4KB 扇区的起始位置开始的,则基本上并不会产生额外的开销。
要有效地实现这样的效果,不仅需要将分区的开始位置与 4KB 扇区的起始边界对齐,还需要将文件系统的块大小调整为 4KB(4096 字节)(关于文件系统的块大小,将在下一节中进行详细介绍)。
在笔者之前介绍的测试机的例子中,第一个分区 /dev/sda1 是从第 2048 扇区开始的。如果将其转换成图 1.5 的逻辑区段数,就恰好和第 256 个 4KB 扇区的起始边界对齐。因为分区中包含的扇区数也是 8 的倍数,因此分区的结束位置也就正好是 4KB 扇区的结束边界。
/dev/sda2 和 /dev/sda3 也是如此,这些分区是在 RHEL6 的安装界面中设置的。可见安装程序很严谨地考虑到了这些问题。
当然,这些都是仅在使用 4KB 扇区的硬盘时才需要注意的事项,但将来一定会有越来越多的硬盘采用 4KB 扇区。因此,在设置分区的开始位置以及大小时,将逻辑扇区数设置为 8 的倍数是比较好的 7,这种方法被称为“分区对齐”。
在用 parted 命令创建 GPT 分区时,默认情况下会指定分区的开始位置、结束位置和容量(例如 MB)。此时,parted 命令会自动对齐所创建的分区。如果不放心的话,可以通过“unit s”命令来指定扇区,对分区的开始和结束位置进行确认。
5恢复系统备份后,有时候需要通过 UEFI 的设置界面重新设置启动对象的启动加载文件。点击 [2] 的链接可以看到使用 IBM System x 时的操作顺序。
6此处为日文资料。——译者注
74KB 扇区的磁盘中有一个被称为“对齐偏移”的功能,在该功能有效的情况下,需要将分区的起始位置设为“8 的倍数 +7”。由于这是 Linux 中不需要的功能,因此在能够通过硬盘的跳线开关等进行更改的情况下,建议禁用该功能。详情请参考 [3]。
1.2.3 文件系统和 I/O 子系统
文件系统的块大小
我们先来重新思考一下文件系统的数据访问。说到文件系统,大家可能会想到 ext3、ext4 等,但在本节,笔者打算从更为宏观的角度来介绍文件系统。
首先,Linux 中,有将各种不同的文件系统统一起来的 VFS(Virtual File System,虚拟文件系统)层,还有通过设备驱动将数据读取或写入物理磁盘的块层,它们一起组成了图 1.6 中所示的 I/O 子系统。Linux 的文件系统只是 VFS 层中的一部分。
图 1.6 I/O 子系统的结构
Technical Notes
[3] Linux Kernel Watch:超过 2TB!ATA 磁盘的 4KB 扇区问题是什么?
前文提到的“文件系统的块大小”,是块层中的设备驱动程序将数据读取或写入物理磁盘的最小单位。从物理磁盘的结构上看,是以 512 字节的扇区单位来读取和写入数据的,但很多时候采用较大的单位来读取和写入数据可以更有效地进行数据交换,而指定这种单位的就是块大小。
在 Linux 中,文件系统的块大小有 1024 字节、2048 字节和 4096 字节这几种选项。默认的块大小被记录在配置文件 /etc/mke2fs.conf 中,也可以通过 mke2fs 命令的 -b 参数来明确指定块大小。若要对已经创建好的文件系统所设置的块大小进行确认,则应使用 tune2fs 命令 9。
我们在上一节中提到,在 4KB 扇区磁盘的情况下,应使分区的开始位置与 4KB 扇区的起始边界相对齐,并将文件系统的块大小也设置为 4KB(4096 字节)。图 1.7 给出了这样做的原因。
图 1.7 中,上图表示了满足这种条件的情况,设备驱动程序对物理磁盘的访问实际上只对应了一个 4KB 扇区,没有产生无效的数据读写。
图 1.7 4KB 扇区和块大小的映射
下图则表示了分区的开始位置与 4KB 扇区的起始边界没有对齐的情况。打个比方,假使设备驱动程序写入了一个块。由于物理磁盘不能只对 4KB 扇区的一部分进行重写,因此需要先读取出两个扇区的数据,按要求对其中的部分数据进行重写,然后再将处理后的数据重新写入这两个扇区。这显然会造成额外的开销。
链接 [4] 里比较了分区的开始位置与 4KB 扇区的起始边界对齐和不对齐的情况下分别对磁盘访问性能造成的影响。据分析,分区开始位置发生偏离时,磁盘的数据写入性能会出现显著的下降。
顺便说一下,类似的情形在常见的 512 字节扇区的磁盘中也会发生,当分区的大小不为块大小的整数倍时,最终会有一个块超出分区。这种情况下,最终的这个块就不会在文件系统中使用。图 1.8 即为块大小为 2048 字节的一个例子,这时分区剩下的最后两个扇区就不会被使用。
Technical Notes
[4] 4KB 扇区磁盘上的 Linux :实际建议
图 1.8 最后的块超出分区的情况
还是题外话,在 fdisk 命令的输出中,有时块数的值后面可以看到附加的 + 记号。比如“1.2.1 磁盘的 3D 参数”一例中,/dev/sda4 的块数(分区中包含的扇区数)后就有 + 记号。当块数为奇数值时,便会附加上这个符号。
这是有其历史原因的。过去的 Linux 文件系统的块大小规定为 1024 字节,因此当分区中的扇区数为奇数值时,就会剩下最后的扇区不被使用。+ 记号就是为了表示“该分区最后一个扇区不会被使用”而特意附上的,但现在这也许是多余的了。
I/O子系统的概貌
我们来回顾一下图 1.6 中介绍的 I/O 子系统的整体结构,分别从写入数据和读取数据两种情况进行考虑。
首先来看看写入数据的情形。当数据被写入文件系统的文件中时,其内容会被暂时录入到磁盘高速缓存中(①)。这时发出写入数据命令的应用程序(用户进程)会将此步骤视为数据已录入成功,从而进行下一步操作。
然而此时就会有些数据只是被写入到了磁盘高速缓存中,而没有被写入到物理磁盘中去。这种数据称为“脏数据”。当磁盘高速缓存上的脏数据积累到一定程度时,文件系统便会向 I/O 调度发出请求,将这些脏数据写入物理磁盘(②)。
这个写入请求将被添加到 I/O 调度内部的“请求队列”中。最后,I/O 调度器会响应请求队列中的请求,利用设备驱动程序将数据写入物理磁盘中(③④)。
大家可能听说过多种类型的 I/O 调度器,例如 cfq、deadline 等。它们之间的差别在于使用的算法不同,即按何种顺序处理请求队列中的请求才最高效。关于 I/O 调度器,之后会做更加详细的说明。
此外,表 1.3 中列出的内核参数,能够用于调整脏数据写入的频率。不过除非有特别的原因,否则是不需要更改默认值的。
表 1.3 与数据的写出频率相关的内核参数
内核参数说明
vm.dirty_background_radiovm.dirty_radio试图将缓存中脏数据的百分比保持在“dirty_background_radio(%)”之下。尤其是当脏数据的百分比超过“dirty_radio(%)”时,将立即增加写出频率
vm.dirty_background_bytesvm.dirty_bytes以字节为单位操作与上面相同的指定。优先于上面的指定,设置为0时则保持上面的指定
vm.dirty_writeback_centisecsvm.dirty_expire_centisecs每“dirty_writeback_centisecs(单位为1/100秒)”检查磁盘缓存,每超过 1“dirty_expire_centisecs(单位为1/100秒)”,就持续写入新的数据
至于数据的读取,则是按以下流程进行的。假设应用程序(用户进程)要从文件系统的文件中读出数据。如果目标数据已经存在于磁盘的高速缓存中,文件系统便将这些数据返回给进程,从而完成处理(①)。
反之,如果目标数据不在磁盘的高速缓存中,那么 I/O 调度的进程会暂停执行并进入等待状态,之后该文件系统会向 I/O 调度器发出读取数据的请求(②)。
最后,I/O 调度器响应请求,通过设备驱动程序把数据从物理磁盘读入磁盘高速缓存中(③④),解除 I/O 调度进程的等待状态,再接着处理磁盘高速缓存中的数据(①)。
磁盘高速缓存和文件系统属于 VFS 层,I/O 调度和设备驱动属于块层,二者相互配合来完成文件中数据的读取和写入。
之所以将它们分为两个不同的层,是基于 Linux 内核中“模块化结构”的概念。这样就可以将不同类型的文件系统和不同类型的物理磁盘随意组合起来使用。
再举个极端的例子,即便是没有物理磁盘的文件系统也能实现数据的处理。例如,ramfs 是利用内存来提供 RAM 磁盘功能的文件系统,它就完全没有图 1.6 的②(数据传输请求)这个步骤。
那么写入的数据究竟到哪里去了呢?实际上它们都保留在磁盘缓存中。若是一般的文件系统,脏数据一旦被写入物理磁盘,就不再是脏数据,因此需要从磁盘高速缓存中删除。
但 ramfs 则不同,磁盘高速缓存中的数据永远是脏数据,永远不会被删除。ramfs 被描述成“基于内存的 RAM 磁盘”,其实准确来说,应该是“基于磁盘高速缓存的 RAM 磁盘”。
在 Linux 中,还有一个和 ramfs 具备相同功能的 tmpfs。tmpfs 同样是将数据保存在磁盘高速缓存中,但有别于 ramfs 的是,它的内容还可以成为“换出”的对象。换句话说,当物理内存不足时,存储在 tmpfs 的文件会被写入物理磁盘的交换空间中,这样文件使用的内存就可以被释放。关于 tmpfs 这种允许换出的机制,在本章 1.4.1 节中也会所介绍。
看到这里,可能有人会产生这样的疑问:“ramfs 和 tmpfs,到底选用哪个好呢?”要是只作一般用途,例如应用程序中所使用的数据的临时存储,建议还是采用 tmpfs。
这是因为,tmpfs 有设置内存使用上限的功能,而 ramfs 则没有这样的功能。如果无限制地往 ramfs 上保存文件,就会持续消耗内存,而且内存部分也不能被换出,最终就会因为内存不足而启动 OOM Killer。
顺带一提,ramfs 的原作者正是 Linus 先生,在源码 ramfs/inode.c 中还能看到 Linus 先生留下的注释。
* NOTE! This filesystem is probably most useful
* not as a real filesystem, but as an example of
* how virtual filesystems can be written.
以上注释都表明,ramfs 只是作为一个最简单的文件系统的示例而创建出来的,它并不适合实际使用。
不过,在 Linux 内核中,有一个地方是使用 ramfs 的。在 Linux 的引导过程中,内核启动后,初始 RAM 磁盘内容 10 会被装载到 RAM 磁盘空间中。这里的 RAM 磁盘空间就需要用到 ramfs。
理解I/O调度器
我们继续回顾图 1.6,看看 I/O 调度器是如何处理文件系统的数据传输请求的。磁盘高速缓存和物理磁盘之间进行数据传输时,磁盘高速缓存中数据的存储方式和物理磁盘中数据的存储方式是不一致的。
首先,在磁盘高速缓存上,一个文件中连续的数据基本上会被写入到连续的存储空间中。然而,同样的数据在物理磁盘上就不一定会被放置在连续的存储空间中了,这种情况被称为“磁盘碎片”。
如图 1.9 所示,磁盘高速缓存中连续的数据被分成了两个部分,分别被写入了物理磁盘上两个连续的扇区内。在本图的例子中,文件的开始和结束部分的内容被设置在了物理磁盘上相邻的位置。此外,Linux 的内存以 4KB 的页为管理单位,磁盘高速缓存中 1 个页能写入 8 个扇区的数据。
图 1.9 磁盘高速缓存和物理磁盘的对应示例
正如图例所示,文件系统的作用就是决定如何将文件的数据分配在磁盘上,而图 1.9 中的对应关系正是记录在元数据区中的。
因此,图 1.6 中文件系统向 I/O 调度器发出数据传输请求时,文件系统就会对物理磁盘上的连续数据进行分割处理。如此一来,I/O 调度器就能够有效地读取物理磁盘上的连续数据。
说得夸张点,即便在考虑性能问题时,对 I/O 调度器的理解也是非常重要的,因此这里会对 I/O 调度器做尽可能全面的介绍。
首先,文件系统会将数据分配到物理磁盘上的连续空间(连续扇区)中,在内核中整合创建出一个 bio 对象,再把这个 bio 对象传递给 I/O 调度器。从图 1.9 中可以看出,有两个 bio 对象要传递给 I/O 调度器。
I/O 调度器接收到来自文件系统的 bio 对象,会将多个 bio 对象合并为一个请求对象,再将它加入到请求队列中。(图 1.10)
图 1.10 I/O 调度器的结构
请求对象的合并方法因 I/O 调度器而异,一般来说,如果 bio 对象指定的连续扇区同时也是请求队列中已有的请求对象所指定的连续扇区,那么就会将这两个对象合并。如果没有一致的请求对象,则创建一个只包含该 bio 对象的新的请求对象。
最后,物理磁盘空间上的数据才会在请求对象所指定的区域进行读取或写入。连续扇区上数据的读取和写入就是这样一并进行的。
此外,这些请求对象的实际处理顺序也是因 I/O 调度器而异的。
例如 cfq(complete fair queuing)调度器,其进程的 bio 对象会被加入到不同的子队列中,以便尽量公平处理每个进程的请求。
从图 1.10 中可以了解子队列的机制。I/O 调度器接收的 bio 对象,首先会被作为请求对象加入到子队列中。子队列中的请求对象增加到一定程度后,便会按照实际传输处理的顺序从子队列转移到调度队列中,进行实际的数据处理。
现在大家应该都明白 I/O 调度器的机制了吧。接下来对具体的 I/O 调度器之间的区别进行介绍。Linux 中主要使用的是以下四种 I/O 调度器,它们的特征分别如下:
noop(no optimization)调度器:只有一个子队列,按照 bio 对象被接收时的顺序处理请求。
cfq(complete fair queuing) 调度器:bio 对象被接收后,获取发送该 bio 对象的进程 ID,根据散列函数,将进程 ID 映射到 6411 个子队列的某个队列中,再处理各个子队列中一定量的请求。这样,每个进程的 I/O 请求都会被公平执行。
deadline 调度器:分别用不同的队列来维护读请求和写请求。将接收的 bio 对象根据其对应的扇区位置插入到请求队列中最合适的位置(位置的排列以尽量减少磁盘头的移动为原则)。但是,当新的 bio 对象被不断插入到请求队列的前方时,队列后面的请求恐怕就会长时间得不到处理,因此需要优先处理一定时间内(读请求为 0.5s,写请求为 5s)没有得到处理的请求。
anticipatory 调度器:在 deadline 调度器的基础上,追加了“预测”下一个 bio 对象的功能。例如,当收到进程 A 发出的读请求后紧接着又收到了来自进程 B 的请求时,就可以预测进程 A 可能很快会发出另一个读请求,这种情况下就要在调度进程 B 的请求之前延迟一段时间(0.7 秒左右),以等待来自进程 A 的下一个读请求。
这些用来处理请求的不同 I/O 调度方法也被称为“电梯算法”。因为要在最大限度减少磁盘头移动的情况下,读取和写入尽可能多的数据,这就好比大厦里的电梯,通过最少的运动承载尽可能多的人。
最早在 Linux 中使用的电梯算法是由 Linus 先生设计的,因此过去也曾被称为“Linus 电梯”。
RHEL6 中默认的 I/O 调度器是 cfq。如果需要对单个硬盘所采用的 I/O 调度器进行修改,可以通过 echo 命令在 sys 文件系统的特殊文件 /sys/block/&磁盘名&/queue/scheduler 中写入 I/O 调度器的名称(noop、anticipatory、deadline 或 cfq)。
下面就是一个变更 I/O 调度器的例子,将 /dev/sda 当前使用的 cfq 调度器更改为了 deadline 调度器。
# cat /sys/block/sda/queue/scheduler
noop anticipatory deadline [cfq]
# echo "deadline" & /sys/block/sda/queue/scheduler
# cat /sys/block/sda/queue/scheduler
noop anticipatory [deadline] cfq
如果只是确认当前的 I/O 调度器,也可以使用 lsblk 命令。如下 SCHED 部分表示的就是当前所采用的 I/O 调度器。
# lsblk -t
ALIGNMENT MIN-IO OPT-IO PHY-SEC LOG-SEC ROTA SCHED RQ-SIZE
如果需要修改系统默认的 I/O 调度器,则在 GRUB 的配置文件 /boot/grub/grub.conf 的内核启动项中设置“elevator=deadline”。
最后需要注意的一点是,对于被 dev/sda 等指定的设备,I/O 调度器可以将其想象成图 1.1 中扇区排列在物理磁盘的磁道上那样的构造,实现访问的最优化。
但是在使用外部磁盘装置的情况下,RAID 阵列的逻辑驱动器(LUN)会被识别为 /dev/sda 等设备。这种时候,存储装置的控制器也能实现磁盘访问的最优化,就不需要 Linux 的 I/O 调度器去进行过于复杂的优化工作了。
仅就一般而言,对于在数据库服务器或服务器虚拟化环境这样使用高性能外部存储设备的复杂环境中的数据访问,有时侯 deadline 调度器要比 cfq 调度器效率更高。
顺便说一下,虽然 cfq 调度器的解释是“对每个进程实行公平的 I/O 处理”,但是为了公平的处理,有时候也需要刻意营造一些不公平。
大家或许并不熟悉,其实通过 ionice 命令可以对 cfq 调度器中的每一个进程设置优先级。优先级有三类,分别是 Real time、Best effort 和 Idle。
和系统上的其他进程相比较,Real time 级别的进程的 I/O 是最为优先被处理的。而 Idle 级别进程的 I/O 正相反,只有在系统上所有进程的 I/O 都处理完了的情况下才处理它。换句话说,除了这类进程之外没有别的进程的 I/O 需要被处理,即系统处于空闲状态时,才会对它进行处理。Best effort 则是默认的优先级,基于 cfq 调度器的正常逻辑,公平地进行 I/O 处理。
对于优先级为 Real time 和 Best effort 的进程,还能在同一级别中依次指定数值为 0~7 的优先级参数,数值越小,优先级越高。
因此,对于那些由于特殊原因不允许延迟的进程,就可以将其优先级设置为 Real time 级别。
例如,RHEL6 标准的 HA 集群系统(High Availability Add-On)中,有一种被称为“仲裁磁盘”(quorum disk)的机制,它通过将数据定期写入共享磁盘来确认服务器是否运行正常。若写入出现延迟,有时会误报服务器发生故障,因此就可以考虑将该进程(qdisk)的优先级设置为 Real time 级别。
但是也要注意,如果某进程有大量的 I/O 处理,若还将其优先级设置为 Real time 级别的话,其他进程的处理就会完全停滞,从而引发其他问题。
关于 ionice 命令的使用,可以参考手册页。要想确认 ionice 的运行效果,则推荐试试下面这个命令。
# ionice -c 1 dd if=/dev/zero of=/tmp/tmp0 bs=1M count=500 oflag=direct & ionice -c 3 dd if=/dev/zero of=/tmp/tmp1 bs=1M count=500 oflag=direct & wait
500+0 records in
500+0 records out
bytes (524 MB) copied, 4.59358 s, 114 MB/s
ionice -c 1 dd if=/dev/zero of=/tmp/tmp0 bs=1M count=500 oflag=direct
500+0 records in
500+0 records out
bytes (524 MB) copied, 9.3951 s, 55.8 MB/s
ionice -c 3 dd if=/dev/zero of=/tmp/tmp1 bs=1M count=500 oflag=direct
这个例子中有两个同时执行的 dd 命令,这里 dd 命令用于导出 500M 大小的文件,它们的优先级分别被设置为 Real time 和 Idle。Idle 优先级的 I/O 处理完全被推迟,在 Real time 优先级的 dd 命令结束后,Idle 优先级的 dd 命令才开始执行。这样一来,Idle 优先级的执行时间正好是 Real time 优先级的两倍(图 1.11)。
图 1.11 Real time 级别和 Idle 级别
dd 命令中的指定选项 oflag=direct,表示直接写入物理磁盘而不使用磁盘高速缓存,常常被用来测量物理磁盘的性能。至于最后 wait 命令的含义,我们将在下一节中进行说明。
8此处为日文资料。——译者注
9RHEL6 的 mke2fs 命令,以及 tune2fs 命令对应于 ext4 文件系统。mkfs.ext4 命令,等同于“mke2fs -t ext4”。
10Linux 初始 RAM 磁盘 initrd 是系统引导过程中挂载的一个临时根文件系统,它与 RAM 磁盘空间是两个概念。——译者注
11默认为 26=64。——译者注
1.3 控制进程就等于控制Linux
众所周知,Linux 上的工作都是通过进程来执行的。若把系统的各种行为拟人化,进程的一生便是时而稳定,时而“死去”又“活来”,可谓十分曲折。有的进程甚至每天都被严密地监视着。
在本节,我们将再次探讨进程管理机制的基本知识,看看这种机制是如何支撑和左右这些进程的命运的。尤其在理解了进程的 fork 之后,脚本的创建技术将得到质的提高。使用 fork 来编写脚本的方法,在本书 3.4.3 节中亦有介绍。
1.3.1 fork 和 exec 分别是进程的分身和变身
登录系统控制台时,需在“login:”提示符处输入用户名进行登录。这里“login:”提示符表示的是接收用户名的输入,执行该步骤的是 mingetty 进程。登录成功后,命令提示符下显示 bash 启动。确切地说,是在 bash 进程启动时,即显示相应的命令提示符。然后,在命令提示符下运行 ls 命令启动 ls 进程,可以显示当前目录下的文件名。
大家可能觉得这些都是小儿科,实际上,这一连串进程动作的发生隐藏了一个至关重要的玄机。我们都知道,在 Linux 上创建进程有 fork 和 exec 两种方法,大家能准确地说出这两者的区别吗?
干脆来做一个小小的实验吧。首先,在运行级别 3 下启动一个 Linux 服务器,之后 SSH 远程登录,查看 mingetty 进程的执行情况。
# ps -ef | grep "mingett[y]"
0 19:21 tty1
00:00:00 /sbin/mingetty /dev/tty1
0 19:21 tty2
00:00:00 /sbin/mingetty /dev/tty2
0 19:21 tty3
00:00:00 /sbin/mingetty /dev/tty3
0 19:21 tty4
00:00:00 /sbin/mingetty /dev/tty4
0 19:21 tty5
00:00:00 /sbin/mingetty /dev/tty5
0 19:21 tty6
00:00:00 /sbin/mingetty /dev/tty6
grep 命令的参数中,通过将它最后一个字母 y 写成 [y] 的方法,可以确保 grep 进程名的结果中不包含有与进程名不符的信息 12。这里找出了 6 个 mingetty 进程。这六个虚拟控制台,可以通过 Ctrl+Alt+F1~F6 分别切换到它们对应的“login:”提示符下。
12思考一下,为什么 grep 命令中指定的检索字符串就可以顺利地使用正则表达式呢?
在控制台中按下 Ctrl+Alt+F1,出现“login: 提示符”,输入用户名,然后输入后回车,出现输入密码的提示。这里请先不要输入密码。
这里再次在 SSH 远程登录的终端,通过 ps 命令查看 mingetty 进程。
# ps -ef | grep "mingett[y]"
0 19:21 tty2
00:00:00 /sbin/mingetty /dev/tty2
0 19:21 tty3
00:00:00 /sbin/mingetty /dev/tty3
0 19:21 tty4
00:00:00 /sbin/mingetty /dev/tty4
0 19:21 tty5
00:00:00 /sbin/mingetty /dev/tty5
0 19:21 tty6
00:00:00 /sbin/mingetty /dev/tty6
mingetty 进程的数量减少了一个,ID 为 1692 的 mingetty 进程似乎消失了。难道这个进程接收一个用户名就会消失吗?
并非如此。这一次,用 ps 命令检索进程 ID。
# ps -ef | grep "169[2]"
0 19:21 tty1
00:00:00 /bin/login --
显示的是 login 进程。知道为什么会这样吗?事实上,mingetty 进程在 exec() 系统调用的作用下,已经转变为 login 进程了(图 1.12 左图)。
图 1.12 exec 和 fork 中进程的变化
Linux 的进程,除了进程主程序代码外还有其他各种各样的信息,比如用于识别进程的进程 ID 就是其中之一。除此之外,还有用于和其他进程或文件交换数据的管道、文件描述符等。
exec 的作用是舍弃进程原本携带的信息,在进程执行时用新的程序代码替代调用进程的内容。mingetty 进程的工作则是接收登录用户名,但之后对密码的验证处理工作则移交给 login 进程继续完成。
图 1.13 是 mingetty 进程中运行 exec 的部分源代码。每行开头的编号表示源代码中实际的行号。454 行的函数 execl,通过 exec() 系统调用切换到变量 loginprog 所指定的程序中去(这个例子中是 /bin/login)13。这之后执行的便是 /bin/login 进程,因此通常 455 行之后的代码便不会再运行了。只有在 exec() 系统调用出现失败的情况下,才会运行 455 行之后的代码。
13调用 exec() 系统调用的 C 语言函数有 execl、execlp、execle、execv、execvp 等。execl 和 execv,由于命令的参数的传递方法的不同,即便最后都附加 p,在 PATH 环境变量中进行命令检索时也仍有区别。
mingetty.c
execl (loginprog, loginprog, autologin? "-f" : "--", logname, NULL);
error ("%s: can't exec %s: %s", tty, loginprog, strerror (errno));
sleep (5);
exit (EXIT_FAILURE);
图 1.13 mingetty 中运行 exec 的部分源代码
之后,exec 开始执行 login 进程,在接收密码输入完成用户认证后,便启动用户的 bash 进程。
之前出于实验需要中断了密码的输入,实际上,只有输入密码才算完成了登录操作。在输完密码的情况下,我们再次检索同一个进程的 ID。
# ps -ef | grep "169[2]"
00:00:00 login -- root
0 19:29 tty1
00:00:00 -bash
login 进程仍然保留了与之前相同的进程 ID(1692)。另外,此次检索结果中还包含了 bash 进程。bash 进程本身的进程 ID(1818)与检索的进程 ID 并不匹配,但是它的父进程的进程 ID(1692)与 grep 检索中的进程 ID 互相匹配了(ps –ef 命令的输出中依次包含用户 ID、进程 ID 以及父进程的进程 ID)。这说明 bash 进程是作为 login 进程的子进程开始启动的。
通常我们说“fork 一个进程”,指的是通过父进程创建一个子进程。但现在的情况是,bash 进程并不是从 login 进程中直接 fork 出来的。从图 1.12 的右图可以看出,fork 生成的子进程是一个与正在运行的进程完全相同的副本。
login 进程通过 fork 生成一个自身的副本后,又在子进程中通过 exec 启动 bash,这一技术简称为“fork-exec”。图 1.14 描述了它的整个流程。
图 1.14 从虚拟控制台登录时伴随的进程的变化
如果诸位仍觉得对 fork-exec 一头雾水,那么就从 login 的源代码中看看 fork-exec 实际的执行过程吧。Linux 和开源最伟大的地方就是无论什么都能亲自探寻。关于如何学习源代码,在本书的第 4 章中也有所介绍,这里只是暂时先在图 1.15 中给出答案。以 // 标记的注释,是笔者自己补充的。
child_pid = fork();
// 子进程的进程 ID 赋值给父进程的 child_pid
// 子进程的 child_pid 中代入 0
if (child_pid & 0) {
// fork 失败时的错误处理
// (部分源代码省略)
if (child_pid) {
// 父进程从这里开始执行
// 这个例子中,等子进程结束后,自己也就结束了
// (部分源代码省略)
/* wait as long as any child is there */
while(wait(NULL) == -1 && errno == EINTR)
// (部分源代码省略)
// 子进程从这里开始执行
/* child */
// (部分源代码省略)
// 子进程通过 exec 变换为 bash
execvp(childArgv[0], childArgv + 1);
图 1.15 login 中进行 fork-exec 的源代码
首先,在 1183 行代码中直接执行了 fork。这里的进程一分为二,且都是从 1184 行开始继续执行的。当然这样并没有意义,因为这两个进程是完全相同的,所以之后子进程需要通过 exec 变换为 bash 进程。
这里用到一个小技巧,即把 fork 的返回值赋给变量 child_pid。fork 执行失败时,父进程返回负值。fork 执行成功时,父进程以及通过 fork 创建的子进程返回的值却不相同。父进程的 child_pid 值为子进程的进程 ID,而子进程的 child_pid 值为 0。
因此,父进程在 1192 行的 if 语句上条件成立,并执行 if 语句中的代码。这个例子中,父进程会一直等待子进程结束,直到子进程退出时,父进程才结束。子进程跳过 1192 行的 if 语句,从 1209 行处开始执行。这个例子中显示的是在 1280 行调用 exec,进程变换为 bash 继续执行。
fork 也可以采用 Perl 等脚本语言来实现。如果你不擅长 C 语言编程,那就请务必牢记这里所介绍的 fork 的处理技术。
最后来看看进程结束的过程吧。首先,父进程 login 通过执行图 1.15 中 1202 行的 wait 函数,一直休眠直到子进程结束。如下例所示,通过 ps 命令确认进程的状态,可看到附加的表示休眠状态的 S 符号。
# ps aux | grep "logi[n]"
0:00 login -- root
这时在已登录的虚拟控制台上显示的 bash 命令提示符处,输入 exit 进行用户注销。此时,通过 exit() 系统调用,bash 进程被终止,同时发送 CHLD 信号给父进程 login。
接收到 CHLD 信号的父进程 login 会退出 wait 函数,同时结束进程。wait 是一个函数,它让父进程在接收到子进程 CHLD 信号之前一直保持休眠状态。
而另一方面,子进程向父进程发送 CHLD 信号,直到父进程接收为止,子进程都处于“僵尸进程”的状态。如果父进程设置为忽略 CHLD 信号,子进程就会一直保持僵尸状态。
在通过 ps aux 来查看进程状态时,僵尸状态会被标记为Z,信息末尾会显示 &defunct&。关于 CHLD 信号等这一类的进程信号,请参见相关书籍。
图 1.16 表示的是 bash 进程在被 exit 终止后的一系列流程。从图中可以看到,login 进程在结束之际向某处发送了 CHLD 信号,那么谁会接收到这个信号呢?
图 1.16 从虚拟控制台退出时伴随的进程的变化
从之前 ps 命令的输出中可以看到,login 进程的父进程是进程 ID 为 1 的 init 进程。因此,init 进程将会接收 CHLD 信号,这时 login 进程就可以从僵尸状态中解放出来。
另一方面,接收到 CHLD 信号后,init 进程会检测到 login 进程(或是原始的 mingetty 进程)已经终止了,于是会再度启动新的 mingetty 进程,这样便可以在虚拟控制台上再次登录了。启动 mingetty 进程需要用到之前介绍的 fork-exec,因此 mingetty 进程相当于 init 进程的子进程。
此外,一直到 RHEL5,init 进程都是根据配置文件 /etc/inittab 来重新启动 mingetty 进程的。但如今发展到 RHEL6,init 进程的启动机制已替换为被称作“Upstart”的新机制,启动时采用的是 /etc/init 下的配置文件。
关于 Upstart,5.2 节会有详细的介绍。
最后介绍一下与 fork-exec 机制相关的进程监控的问题。
用 fork-exec 创建新的进程时,最初 fork 的瞬间,子进程的进程名和父进程的进程名是相同的。之后才通过 exec 变更为新的过程名。因此,在进行进程监视,对特定进程名的进程数量进行检查的时候,在 fork-exec 执行的那一瞬间,需注意进程数量可能大量增加。
如果设定为“不允许存在同样命名的进程”的话,fork-exe 和监视间隔的时间会出现微妙的一致,从而误检测到异常的进程数。这种现象并不少见,因此为了避免判断时带来麻烦,请务必注意到 fork-exec 的存在。
1.3.2 作业控制中的各项任务处理
前文介绍了进程启动的基本原理。下面再来介绍一些实用的技术吧。
首先,在 shell 命令提示符下运行命令,通常在命令执行完毕后,命令提示符会再度出现,这与 login 进程启动 bash 进程的流程(图 1.14)是一样的。
bash 进程首先响应命令,通过 fork-exec 创建了一个新的进程,之后便会一直处于休眠状态直到子进程结束。接着 bash 进程接收到 CHLD 信号,确认子进程已终止,再次显示命令提示符,提示接收下一个命令。
在命令的末尾加上“&”符号,可以让命令在后台运行。这种情况和上文一样,bash 进程会通过 fork-exec 创建新的进程,不同的是,此时 bash 进程不再需要等待子进程结束,便可以直接显示命令提示符,提示接收下一条命令。在执行比较花时间的命令时,让多条命令并行执行不失为一种便利的方法。
但这里存在一个应用的问题。怎样才能让多条命令并行执行,并且等待所有命令都执行结束呢?
举个例子,可以考虑通过 shell 脚本实现 SSH 在 10 台服务器上执行远程命令,并等待所有服务器上的命令执行完毕。图 1.17 是笔者实际使用过的 shell 脚本的例子,其目的是在三台服务器(node01~node03)上启动群集服务(clstart)[5]。为方便起见,将该脚本命名为 clstart_all.sh。
Technical Notes
[5] High Availability Add-On 设计和运用入门“集群的启动顺序” 14
14此处为日文资料。——译者注
例 1 在每一台服务器上执行
ssh node01 /usr/local/bin/clstart
ssh node02 /usr/local/bin/clstart
ssh node03 /usr/local/bin/clstart
例 2 命令执行完之前结束
ssh node01 /usr/local/bin/clstart &
ssh node02 /usr/local/bin/clstart &
ssh node03 /usr/local/bin/clstart &
例 3 命令执行完之后退出
ssh node01 /usr/local/bin/clstart &
ssh node02 /usr/local/bin/clstart &
ssh node03 /usr/local/bin/clstart &
图 1.17 在多个服务器上并行执行命令的 Shell 脚本(clstart_all.sh)
如例 1 中所示,node01 运行 clstart 结束后,node02 接着执行,即每一台服务器按顺序依次执行。这显然是很浪费时间的。
因此,在例 2 中,笔者尝试在后台执行每个命令。这种情况下,三台服务器能同时正确执行 clstart 命令。但是,在每一台服务器上的 clstart 命令运行结束前,脚本的运行就已经结束了。
这里就出现了一个问题,即没法在所有服务器的群集服务启动完成后,再接收下一条输入命令。因为运行于后台的命令还未显示输出信息,命令提示符的显示就已经提前出现了,从而导致控制台显示出现混乱。
于是,例 3 中作了进一步的改良。虽然只是在最后添加了 wait 命令,但这是 bash 的一个内部命令,其功能与在图 1.15 的第 1202 行出现的 C 语言的 wait 函数相类似。
wait 命令执行后,clstart_all.sh 便进入休眠状态,等待后台运行中的子进程全部结束后发来 CHLD 信号通知。所有的子进程结束后,再处理 wait 之后的下一个命令。
这里 shell 脚本的 wait 命令结束了,因此所有服务器上的 clstart 命令结束的时候,clstart_all.sh 也就结束了。这种处理方法被称为“异步执行的等待处理”。
所谓“异步执行”,指的是各自处理后台进程。另外还有一个“等待处理”,指的是父进程在等待所有子进程都报告执行完毕后,再进行下一步处理。这是一种常用技术,例如在多线程并行编程中就常常被使用到。
此外,在例 2 中,后台 ssh 命令仍然还在执行,其父进程 clstart_all.sh 便先行结束了。这种父进程先于自己结束的子进程便被称为“孤儿进程”。
由于父进程已结束了,进程结束时发出的 CHLD 信号便无法被接收,从而产生僵尸进程。因此一旦产生孤儿进程,进程 ID 为 1 的 init 进程便会自动成为它们的父进程。孤儿进程所发出的 CHLD 信号,均由 init 进程接收并确认进程结束,自然也就不必担心产生僵尸进程了。
以上示例均展示了如何通过 shell 脚本在后台执行子进程。另外,直接在后台执行命令提示符下达的命令时,应格外注意 shell 输出重定向的最终处理。
若将后台执行命令的输出结果直接显示在屏幕上,接下来输入的命令就会将前面的输出结果覆盖掉,这是很麻烦的事情。因此可以将在后台执行的命令的屏幕输出,重定向到文件中。以下列举了三种具有代表性的例子。
# command &cmd.log 2&cmd.err & ←-标准输出写至文件cmd.log中,标准错误输出写至文件cmd.err中
# command &cmd.log 2&&1 & ←--------------标准输出和标准错误输出都写至cmd.log中
# command &/dev/null 2&&1 & ←------------丢弃标准输出和标准错误输出
一般来说,在屏幕上输出的 Linux 的进程内容分为两类:“标准输出”(stdout)和“标准错误输出”(stderr)。
默认情况下两者都会在执行命令的终端上输出内容,但 stdout 和 stderr 被定义为程序中的两个不同输出来源。进程的正确处理结果被送往标准输出,其他的错误信息则被送往标准错误输出。
第一个例子中,命令(command)的标准输出写至文件 cmd.log 中,标准错误输出写至文件 cmd.err 中。标准输出和标准错误输出,分别用文件标识符 1 和 2 来标识,因此正确的写法应该为“1&cmd.log 2&cmd.err”。但是,由于表示标准输出的“1”是默认值,故在这里可以省略不写。若需要在现有文件之后附加内容,就请用“&&”来代替“&”。
在第二个例子中,标准输出和标准错误输出都写至同一个文件 cmd.log 中。“2&&1”表示的是,将标准错误输出和标准输出统一到一个位置。
最后一个例子中,是将输出写入一个特殊文件 /dev/null 中。/dev/null 会将输出内容作丢弃处理,稍微了解一点 Unix 知识的人会称之为“黑洞”。这里需要注意的是,如果出现拼写错误,例如写成“/dev/nul”,就会在 /dev/ 之下创建一个输出文件。
需要与 /dev/null 一起记住的是 /dev/zero。这是一个持续读取字节码 0(NULL 值)的特殊文件。如下所示,通过这种方式可以创建内容为 NULL 的 100M 的空文件。
# dd if=/dev/zero of=/tmp/tmp0 bs=1M count=100
为了让 top 命令的执行更有意思,可以让 CPU 持续不断地被使用,下面就是一个类似的有趣的例子。
# cat /dev/zero &/dev/null
还应该注意的一点是,用 exit 命令(或者是 logout 命令以及 Ctrl+D 等)结束 bash 时的动作。
运行于终端屏幕上的 bash 进程,会在结束的时候向每一个子进程发送 HUP 信号。由于 HUP 是一个令进程终止的信号,因此在这种情况下,运行于后台的命令也会一并结束。
大家或许都有过如下的困扰。比如在后台运行较为耗时的基准测试程序(benchmark)时常以失败告终,或者是在 PC 上使用 Putty15 连接远端服务器来运行基准测试程序时,由于运行太过耗费时间,便不得不让 PC 长期开着。
15Putty 的正式写法是 PuTTY,它是一个 Telnet、SSH、rlogin、纯 TCP 以及串行接口连接软件。——译者注
使用 nohup 命令即可解决这个问题,它可以在命令执行时设置成忽略 HUP 信号。使用方法如下图所示。
# nohup command &cmd.log 2&&1 &
这样一来,即便通过 exit 命令结束了 bash 进程,运行于后台的命令(command)也能无视 HUP 信号继续执行。但是由于身为父进程的 bash 进程结束了,因而 /sbin/init 就成为孤儿进程的新的父进程。
除了 nohup 命令以外,screen 命令也很实用。通过使用 screen 命令,能够在 Putty 的文本终端上创建多个虚拟终端窗口。并且即便关闭了 Putty 的文本终端,这些虚拟终端仍会继续运行。这种情况下,重启 screen,便能再次连接上之前的虚拟终端而恢复工作。
这么解释或许难以理解,还是来实际操作试试吧。因为并不一定默认安装 screen 命令,所以如果 screen 命令不存在的话,则导入同名的 RPM 包即可。
# yum install screen
在 SSH 连接终端上,运行 screen 命令。
终端窗口顶部的标题显示的是 [screen 0:bash],它表示连接的是第 0 个虚拟终端。在顶层窗口运行相应的命令,例如输入 Ctrl+A C(先按下 Ctrl+A,再按下 C),就创建了第一个新的虚拟终端。此时顶层窗口消失,屏幕上重新显示 bash 命令提示符,窗口的标题部分变为 [screen 1:bash]。
若多次输入 Ctrl+A C,就能随意创建新的虚拟终端。之后输入 Ctrl+A“”,就能返回到任意指定数字的虚拟终端上。例如,当你想返回第 0 个虚拟终端,输入 Ctrl+A 0,屏幕上就会显示运行中的 top 命令。至于 10 以后的虚拟终端,在 Ctrl+A ’(单引号)后加上数字即可。还可以用 Ctrl+A N 和 Ctrl+A P 在虚拟终端间进行前后顺序的来回切换。
此外输入 Ctrl+A D,还可以离开创建的虚拟终端,返回 screen 命令执行之前的屏幕。乍一看似乎 screen 命令已经结束了,但实际上它仍然在后台运行,之前创建的虚拟终端的会话也仍然存在。
终端关闭后,需要重新建立一个 SSH 连接,运行附加了 -r 选项的 screen 命令。
# screen -r
输入 Ctrl+A D 之前的虚拟终端窗口再次出现,这样就可以恢复工作了。
通过这种方式,便可以在 screen 命令的虚拟终端上放心地运行耗时的基准测试程序。screen 命令也能用于创建自定义的操作环境,具体可以参考手册页。
最后,来介绍一下任务管理的基本命令。
在 bash 命令行的末尾附加 & 可以让进程运行于后台,此时除了进程 ID 外,还会被分配一个只能在 shell 环境中使用的“作业 ID”。
使用 jobs 命令可以查看正在运行中的作业。当 kill 命令向指定进程发送信号时,可以使用作业 ID 替代进程 ID,例如“%1”即表示指定作业 ID 为 1 的进程。
至于没有加上 & 符号的在前台运行的命令,通过 Ctrl+Z 将它暂停之后再运行 bg 命令,就可以将其切换到后台。如果运行的是 fg 命令,则进程会再度回到前台运行。
或者指定作业 ID 并执行 fg 命令,也可以将运行于后台的命令切换到前台。
图 1.18 作业的状态变化
笔者有时在 vi 编辑器中进行操作时,会不小心误按 Ctrl+Z。这时编辑器的界面会消失并返回到命令提示符处。出现这种情况时请不要惊慌,只需运行 fg 命令,就能安全返回 vi 编辑器的界面了。
1.3.3 快速的数据处理管道
最后就用管道处理的知识来结束进程这个话题吧。虽然管道只是一个基础知识,但其中蕴含着 Linux 从 Unix 中继承的重要概念。
管道机制是由 Doug McIlroy 发明的,他写下了如下这段话:
“程序应该只关注一个目标,并尽可能把它做好。程序应能够互相协同工作。应该让程序处理文本数据流,因为这是一个通用的接口。”16
16Douglas McIlroy 是 Unix 系统上管道机制的发明者,也是 Unix 文化的缔造者之一。这三句话是他归纳的 Unix 哲学,非常经典。原文:Write programs that do one thing and do it well. Write programs to work together. Write programs to handle text streams, because that is a universal interface.——译者注
说这段话的前提在于,“用户比起程序员更加清楚应该用电脑干嘛 17”。用户利用管道的机制,能够自由组合程序员提供的的各种功能,从而实现必要的处理。
17与此相反的想法是,“由程序员来决定程序的功能(用户需要的功能)”。基于这一想法开发的应用程序虽然使用简单,但是存在应用不佳的缺点。由于用户所需要的功能是与时俱进的,因此经常需要进行大规模的更新。
特别是服务器管理员,经常被要求在现有条件下充分利用可用的工具,快速完成工作。虽然很多便利的工具在 Web 上就能检索到,但是如果没有时间安装这些工具,或者是服务器不允许安装多余的工具,这个时候就需要开动脑筋对 Linux 服务器里的自带工具进行灵活组装了。
管道处理的基础是标准的输入输出数据。之前介绍了程序的两种输出:标准输出(stdout)和标准错误输出(stderr)。另外,程序的数据输入源是标准输入(stdin)。默认情况下,标准输入指的是从终端的键盘输入。
例如 cat 命令,通常用于将指定文件名的内容输出到屏幕。如果没有指定文件名,则把从标准输入中接收的内容一行一行地依次发送到标准输出上(图 1.19 的上图)。
图 1.19 标准输入输出和重定向
Hello, ←-------------从键盘输入
Hello, ←-------------在屏幕上输出相同的内容
World! ←-------------从键盘输入
World! ←-------------在屏幕上输出相同的内容
←--------按Ctrl+D结束
把标准输入输出的位置从默认的键盘和屏幕切换到文件,这种处理称为“重定向”。在之前 stdout 和 stderr 的重定向的介绍中,提到过 & 符号的使用方法。这里将标准输入的位置变更为文件的话,使用 & 符号即可(图 1.19 的下图)。
下例是先把从键盘输入的内容保存到文件 greeting.txt 中,再在屏幕上重新显示。
# cat & greeting.txt
Hello, ←-----------从键盘输入
World! ←-----------从键盘输入
←--------按Ctrl+D结束
# cat & greeting.txt
而将这两个进程的标准输出和标准输入直接相结合的,就是管道(图 1.20)。两个命令之间用“|”号相连。
图 1.20 管道处理
此前,使用 grep 过滤 ps 命令的输出结果便可以写成如下。
# ps -ef | grep "mingett[y]"
当然,也可以通过管道来连接三个或更多的命令。大家以前使用的命令中有多少使用了管道呢,回想一下是不是很有意思? 18
18笔者回想了一下,发现自己大量使用的是“history|grep~”。在确认过去执行的命令的选项时,常常会用到这种方法。
# history | grep "|"
在管道中,除 grep 命令外,还常常会用到 cut、tr、sort、uniq、paste、wc、head 等命令。表 1.4 总结了这些命令的使用说明。关于每个命令的更多详细信息,请参考手册页。
表 1.4 管道中常用的快捷命令
通过分隔符拆分后,显示指定的域
显示与模式相匹配的行
显示文件的开始部分
通过指定的分隔符将两个文件的各行进行合并,或者通过指定的分隔符合并一个文件中的多行
对多行进行排序
替换、删除字符,压缩文字序列
压缩连续的相同的行
显示字节数、字数、行数
接下来做一个练习题吧。首先,将命令“ps -ef”的输出保存到文件 /tmp/tmp0 中。用 head 命令查看前五行的内容,其结果如下所示。
# ps -ef & /tmp/tmp0
# head -5 /tmp/tmp0
C STIME TTY
00:00:06 /sbin/init
00:00:00 [kthreadd]
00:00:00 [migration/0]
00:00:02 [ksoftirqd/0]
举一个现实中的例子,通过 cron 作业将过去的 ps 命令的输出保存在文件中,以便之后可以作各种各样的分析。但在这里,请仅从文件中提取进程的 ID(PID),然后写入另一个文件中。
方法还有很多,请大家既不要检索 Web 也不要参考手册页,用立刻就能想到的方法来试试看吧。
下面的例子给出了一个比较经典的答案。
$ cat /tmp/tmp0 | tr -s " " | cut -d " " -f 2 | grep -v "PID" & /tmp/tmp1
cut 命令把 -d 选项指定的文字作为文字分隔符,仅抽取 -f 选项定位的数据(域)。这个例子中,使用空格作为分隔符。但是,由于连续的空格会被当作多个分隔符,因此要事先通过 tr 命令将连续空格转换成一个空格。记住 tr 命令和 cut 命令这个组合,用起来十分便捷。
最后,使用 grep 命令删除标题行,将结果写入文件 /tmp/tmp1 中。grep 命令的 -v 选项的意思是,只取出与条件不一致的行。
此外,使用这种方法的时候,要注意行的顶头是否包含了空格。这样的行中,因为顶头的空格也会被当作分隔符,所以域的位置就会后错一位。后面将会介绍如何删除行顶头处的空格。
tr 命令和 cut 命令的组合也可以用 awk 来替代。
# cat /tmp/tmp0 | awk '{print $2}' | grep -v "PID" & /tmp/tmp2
awk 使用连续的空格作为分隔符,自动分解输入的数据。这里 print$2 指的是打印位于第二个字段的数据。由于行头的空格会被自动忽略,因此不需要像前例那样事先去除位于行头的空格。
如果想使用空格以外的分隔符,可以通过 -F 选项来指定。下面是一个被保存在 /etc/passwd 文件中的用户名列表的输出例子。
# cat /etc/passwd | awk -F ":" '{print $1}'
顺便说一下,如果需要输出正在运行的进程的 ID 列表,使用 ps 命令的选项即可,不需使用管道。
# ps -e --no-headers -o pid & /tmp/tmp3
但是,使用这个选项就和之前保存的形式略有不同。之前不论是 /tmp/tmp1 还是 /tmp/tmp2 中进程 ID 的记录都是采用行头左对齐的方式,但是在 /tmp/tmp3 中,进程 ID 则采用右对齐,且行头包含空格。
这里可以稍微多做一些工作,组合使用 head 命令和 tail 命令,去除文件中的部分内容进行比较。
# head -98 /tmp/tmp1 | tail -6
# head -98 /tmp/tmp3 | tail -6
如上图所示,/tmp/tmp3 的行头的确包含了连续的空格。接下来就思考一下如何去除行头空格的方法吧。
对于这种置换处理,比较经典的方法是使用 sed。如果需要将每行中的 AAA 替换成 BBB,一般用“sed "s/AAA/BBB/g"”即可。s 即为用来指定替换的 sed 命令,g 则表示替换行内出现的所有 AAA。如果省略了最后的 g,则只会替换行内出现的第一个 AAA。
tr 命令也可以实现相同的处理,不过和 sed 不同的是,后者允许使用正则表达式。由于这里我们需要去掉行头的连续空格,因此如下用空字符串来替换即可。
# cat /tmp/tmp3 | sed "s/^ *//" & /tmp/tmp4
在 shell 脚本中常常会用到 awk 和 sed,这是读懂别人所写的 shell 脚本的必备知识。虽然 Web 上就有铺天盖地的关于这方面的知识,但笔者还是建议大家多找些 [6] 这一类的书,深入地研究学习一下。此书对 sed 和 awk 中允许使用的正则表达式也做了详细的解说。
awk 尤其具有很强的通用性,不仅可以进行文本处理,还可以用来代替 perl 等高级语言。当然,用 perl 就可以轻易实现的处理就没必要非得用 awk 了,但把 awk 作为一种技巧,总能在意想不到的地方起到意外的作用 19。
19笔者之前还曾把用 Perl 编写的脚本移植到不支持 Perl 的陈旧的 Unix 服务器中,进行从 Perl 到 awk 的移植操作。
在管道处理中,结合使用符号 $()也是一种便利的技术。这是 bash 的一个功能,把命令输出插入 $()内,作为命令的一部分。举个例子,请思考下面这个命令是用来干什么的。
# yes "kill" | head -$(cat /tmp/tmp4 | wc -l)
$()中,输出的是文件 /tmp/tmp4 中所包含的行数。假设这个结果是 150,那么这个命令等效于下图。
# yes "kill" | head -150
上图表示的是 150 行“kill”的字符串。yes 命令用来无限输出指定的字符串,这里即表示提取了前 150 行。
有一点需要注意,head 命令并不是在 yes 命令结束之后才执行的。因为如果是那样的话,yes 命令永远不会结束,管道处理也就永远不会结束了。
实际上,head 命令在 yes 命令开始输出的同时就接收其输入了。head 命令接收了 150 行的输入后,就会先于 yes 命令结束。
Technical Notes
[6] 《sed 与 awk(第 2 版)》Dale Dougherty、Arnold Robbins(著),张旭东等(译),机械工业出版社,2003
此时,yes 命令的输出已无法被接收,但如果它试图再次输出的话,就会收到 pipe 信号,然后 yes 命令才会终止。
当然,这只是一个虚构的小例子,下面我们来看一个经常会用到的实例。
# mkinitrd -f /boot/initramfs-$(uname -r).img $(uname -r)
这是一个用来指定启动内核的版本号,创建初始 RAM 磁盘文件的命令。uname –r 的运行结果就是内核的版本号。
介绍完了管道处理,下面我们来作一些练习吧。
① 将用 root 用户以外的用户权限运行的进程 ID 写入文件 /tmp/tmp0 中。
② 在文件 /tmp/tmp0 每行的开头,增加字符串“kill -9”,然后再保存到文件 /tmp/tmp1 中。
③ /tmp/tmp1 可以作为一个脚本,用来杀死 root 用户以外的进程。不使用 /tmp/tmp0 这样的中间文件,请组合出一个能够执行相同处理的命令。
以上这些小问题,都是为了让大家了解更多不同的命令。当然,每一题的答案也都不是唯一的。
首先提供一个利用了管道处理方法的解答。
# ps -ef | grep -E -v "^(root|UID)" | awk '{print $2}' & /tmp/tmp0
grep 命令指定了选项 -E,就能使用范围更广的正则表达式,即“扩展正则表达式”。使用扩展正则表达式来匹配每行开头中的 root 或者是 UID,这里将包含它们的所有行都删去。UID 位于 ps 命令第 1 行的标题部分。
但如果是查找满足一定条件的进程的进程 ID,则使用专门的 pgrep 命令更为简单。
# pgrep -v -u root & /tmp/tmp0
有关 pgrep 命令中选项的更多信息,请查看手册页。比起使用复杂的管道,这个例子教会我们,调查一下是否有实现同样功能的命令也很重要。
前面的例子介绍了用空字符串替换(也就是删除)行头的连续空格。这里却恰恰相反,用指定的字符串替换行头的空字符串,即在行头插入指定的字符串。下面是一个使用 sed 的例子。
# cat /tmp/tmp0 | sed "s/^/kill -9 /" & /tmp/tmp1
另外,使用 awk 也是可行的。
# cat /tmp/tmp0 | awk '{print "kill -9 " $0}' & /tmp/tmp1
awk 中,$n 表示的是第 n 个域,$0 表示的是域分解前的整行字段 20。除此之外,也可以使用之前 yes 命令一例中的方法。
# yes "kill -9 " | head -$(cat /tmp/tmp0 | wc -l) | paste -d " " - /tmp/tmp0 & /tmp/tmp1
paste 命令,是用选项 -d 指定的分隔符将两个文件按行合并。在这个例子中,第一个文件名的地方使用的是 -,这意味着使用的是标准输入而不是文件。生成与 /tmp/tmp0 同行数的 kill -9,再通过 paste 命令将它们与 /tmp/tmp0 进行合并。
做到这一步或许有些多余,但都是为了让大家记住 paste 命令的使用方法。之后还会介绍其他用例,请提前参看手册页中关于 paste 命令的 -s 选项。
利用管道完美地结合①和②的答案,就可以避免使用 /tmp/tmp0 作为中间文件,但这里就不再详述了。实际上,和 pgrep 命令一样,有专门的 pkill 命令用来实现这种功能。
# pkill -9 -v -u root
当大量的进程一起停止的时候,就需要将通过 ps 命令显示的进程 ID 逐条复制和粘贴。但如果使用 pgrep 命令和 pkill 命令,就能简化这些工作。所以请务必多加练习这些方法。
关于管道处理的介绍就到此结束了。要知道同样的工作可以有一百种方法。要判断哪种方法最合适是需要经验的,这里介绍“透明性”(transparency)和“发现性”(discoverability)这两种思路供参考。
所谓透明性,指的是有经验的人看到代码时,能很快理解该代码是用做什么处理的。可以说透明性高,就意味着较难发生意料之外的错误(即所谓的 bug)。可以这么想,别人看不懂的复杂代码,其中一定存在你自己也不能理解的潜在问题。
而发现性,指的是即使不通过努力钻研,也能立刻上手使用代码,没有经验的人使用起来也能得心应手。高发现性的代码,能够有效地应用于各种场合。
以上观点都是为了避免过度使用复杂选项。毕竟在不能详细检查命令选项的情况下,可以快速应用的技术是非常重要的。
此外,由于透明性和发现性并非一定同时成立,因此需要根据实际状况来判断优先考虑哪个。推荐大家从这两者角度,重新看看曾经使用过的命令或者写过的脚本。
顺便提及,[7] 是一本通俗易懂的经典著作,讲述了设计开发 Unix 的一群人的思想和经验教训。“只知道 Linux,没有接触过 Unix”的年轻工程师们,读一读这本书,或许能从一个全新的视角来审视 Linux 呢。
20awk 借用 shell 的方法,用 $1,$2,$3... 这样的方式来顺序地表示行中的不同字段,用 $0 表示整个行。——译者注
1.4 至关重要的内存管理
最后让我们进入 Linux 内部结构的关键部分——内存管理吧。因为 Linux 内核中的管理机制对 Linux 的内核性能有很大影响,因此其构造被设计得极为精妙。
例如,上一节介绍的管道处理是由多个进程共同完成同一项工作,此外,还有在一个进程内处理多个操作的“多线程”机制。虽然很多人认为“多线程的性能优于多进程共同工作的机制”,但也不能盲目地这么理解。
在早期的 Unix 系统中,利用之前介绍的 fork 和 fork-exec 启动新进程时,由于从父进程往子进程复制内存信息需要花费一定的时间,因此启动多个进程时难免性能表现不佳。但是,现在的 Linux 内核中则不会发生类似的情况。通过之后将要介绍的“写时复制”(copy-on-write)等机制,反而可以更加高效地创建进程。
说句题外话,关系数据库(RDB)是一个高性能的应用。开放源代码中有代表性的 RDB 有 MySQL 和 PostgreSQL,相对于使用多线程的 MySQL,PostgreSQL 采用的就是多进程共同工作的机制。
Technical Notes
[7] 《UNIX 编程艺术》Eric S.Raymond(著),姜宏等(译),电子工业出版社,2006
至于这样设置的原因,一位 PostgreSQL 的开发者给出了如下回答:“利用多线程来提高性能,需要复杂的机制,其结果会导致程序中出现 bug 的可能性升高,而分析这些问题也需要花费更多的时间”。这种思考方法可谓与之前介绍的“透明性”和“发现性”不谋而合。
很多书已对 Linux 的内存管理做过全面介绍,本书的侧重点在于对进程管理和磁盘管理的相关内容做更进一步的深入讲解,比如用户进程中使用的内存管理等。
1.4.1 物理内存的分配
首先复习一下内存管理的基础——物理地址空间和逻辑地址空间的关系。
安装于服务器中的物理内存,大致可分为 Linux 内核自身使用的区域和用户进程使用的区域。图 1.21 是 x86 架构(32 位)的分配方式。Linux 内核仅使用 1MB~896MB 空间大小的低端内存,其他没被使用的空间则被分配给了用户进程。如图 1.21 中,896MB 以上的高端内存空间被分配给了用户进程,不光如此,低端内存中的空闲区域也被分配给了用户进程使用。
图 1.21 内核内存和用户进程内存的配置(x86 架构)
此外,包括内核本身在内,服务器上运行的程序在访问物理内存时,并不直接指定物理地址(图 1.21 中的“物理地址空间”所指定的地址),而是指定逻辑地址(图 1.21 中的“逻辑地址空间”所指定的地址)。首先在内存的内核数据区域中预先设置逻辑地址和物理地址的对应“页表”,然后 CPU 上搭载的 MMU(Memory Management Unit)硬件会参照该页表,自动实现对映射后物理地址上的数据的访问。
从图 1.21 中可以看出,因为每个进程有不同的逻辑地址空间,所以分别为每个进程准备了一个页表。MMU 参照的是该 CPU 上运行的用户进程所对应的页表。
此时,内核使用的低端内存区域,会在全部页表中被共同映射到 3GB ~4GB 的逻辑地址空间上。因此,不管当前运行的进程是什么,Linux 内核本身就能始终使用 3GB ~4GB 的逻辑地址。
像这样,通过为每个进程提供独立的逻辑地址空间,每个进程的内存访问就各自独立。进程 A 不能从自己的逻辑地址空间访问到进程 B 的物理内存,这也等于实现了进程之间的安全保护。
虽然图 1.21 是基于 x86 架构的,但在 x86_64(64 位)架构下,其基本机制也不会改变。
X86 架构中的逻辑地址空间范围限制在 4GB,其中内核可以使用的地址空间也限制在 3GB~4GB 之间 21。但在 x86_64 架构下,其逻辑地址空间范围不限定于 4GB,因此内核可以自由使用更大范围的内存空间。
21用户空间占 3G,内核空间占 1G。——译者注
这样一来,也就不存在低端内存和高端内存之分了。在 x86_64 架构中,所有内存空间都被认为是可供内核使用的低端内存。
内核管理知识的复习就到此为止。下面来看一看可分别供用户进程和内核使用的内存有哪些类型。这里,我们把用户进程运行使用的内存以及磁盘高速缓存等称为“用户内存”,其他用于内核自身运行的内存称为“内核内存”。
参考 proc 文件系统中的特殊文件 /proc/meminfo,我们来介绍一下用户内存和内核内存分别有什么样的类型,以及如何查看它们各自的使用量。从 /proc/meminfo 中可以得到内核使用情况的各种信息,但由于基本是把内核内部的管理信息原封不动地输出,如果不清楚内核的内部构造,也就无法理解其中的含义。
于是干脆反其道而行之,通过这个输出来理解与内存管理相关的内核的内部构造。
用户内存的分类
图 1.22 是在笔者在 RHEL6.2 服务器上的输出。从最开始的 2 行(MemTotal,以及 MemFree)可以看出,安装了空间为 8GB 的物理内存,当前的空闲内存为 3GB 左右。其后显示的是各种各样的信息。
之前说的“用户内存”,是通过① ~ ④以及紧接其下的 Unevictable 的总和来分配的。这个例子中约为 4.3GB。① ~ ④的值可以按表 1.5 进行整理,至于 Unevictable,我们稍后再作介绍。
表 1.5 用 LRU 列表对用户空间的使用内存进行分类
ActiveInactive
匿名内存3282428KB588724KB
File-backed内存219448KB214596KB
匿名内存和 File-backed 内存的区别在于,物理内存的内容是否与物理磁盘上的文件相关联。
匿名内存,是用来存储用户进程用作计算的中间数值的,灵活确保程序在执行时有可用的内存空间。如果熟悉 C 语言的话,说成是“通过 malloc() 分配的内存”会更容易理解,其内存中的内容自然与物理磁盘上的文件没有任何关系。
# cat /proc/meminfo
8069288 kB
3051032 kB
SwapCached:
3501876 kB
←---Active(anon) + Active(file)
←---Inactive(anon) + Inactive(file)
Active(anon):
3282428 kB
Inactive(anon):
Active(file):
Inactive(file):
Unevictable:
SwapTotal:
2097144 kB
2093400 kB
Writeback:
AnonPages:
3846228 kB
SReclaimable:
SUnreclaim:
KernelStack:
PageTables:
NFS_Unstable:
WritebackTmp:
CommitLimit:
6131788 kB
Committed_AS:
7762476 kB
VmallocTotal:
VmallocUsed:
VmallocChunk:
……( 以下省略 )……
图 1.22 /proc/meminfo 中的内存信息
而 File-backed 内存作为磁盘高速缓存的内存空间,其物理内存中的内容与物理磁盘上的文件是相对应的。例如,将物理磁盘上的文件内容读入到磁盘高速缓存中使用时,同样的数据也同时存在于物理磁盘上。此外,还有用于“文件映射”的内存空间,能将物理磁盘上的文件内容与用户进程的逻辑地址直接关联。
此外还有 Active 和 Inactive 的区别,即该内存上的数据最后一次被使用的时间。包含有刚被使用过的数据的内存空间被认为是 Active 的,包含有长时间未被使用过的数据的内存空间则被认为是 Inactive 的。当物理内存不足,不得不释放正在使用的内存空间时,会首先释放 Inactive 的内存空间。
匿名内存和 File-backed 内存的内存释放方法也有所不同,这一点稍后再作讨论。
为了将用户内存分为以上 4 类,Linux 内核中有着与表 1.5 相对应的 4 类“LRU(Least Recently Used)列表”,用户内存的各个内存页都会记录在其中某一个列表上。LRU 在日语中是“最不常用”的意思,前文中提到,包含长时间未被使用过的数据的内存空间会被优先释放,由此而得名。
“内存页”这个术语可能是第一次出现。Linux 内核将物理内存分割成 4KB 大小的“页”来进行管理,内存的分配、释放等处理都是以页为单位进行的。
至于 Active 和 Inactive 的分类,则是根据内存的使用情况而灵活变动的。当对 Inactive 的 LRU 列表记录的内存页进行访问时,该内存页便会移至 Active 的 LRU 列表中。反之,Active 的 LRU 列表记录的内存页中如果有长时间不被访问的数据,又会被移至 Inactive 的 LRU 列表中。
接着补充说明一下 Unevictable。前文介绍了“4 种 LRU 列表”,实际上,还有一种 Unevictable 列表,记录的是“Unevictable”类型的内存页。
在下一节中会介绍到,使用 LRU 列表对内存页进行分类,是为了更有效地对内存进行释放处理。然而,原则上内存页中也有不能被释放的部分。这种内存页,记录到之前介绍的 4 种 LRU 列表中是没有意义的,因此改为记录到 Unevictable 列表中。
这里通过 free 命令查看同一台服务器的内存使用情况,其结果如下所示。
-/+ buffers/cache:
一般认为,在 free 命令的输出中,buffers 和 cached 的值的总和就是可以使用的磁盘高速缓存的大小。图 1.22 中的⑤⑥即表示了这两个值。
那么,“buffers+cached”的值和表 1.5 中的分类又会有什么样的关系呢?
如果囫囵吞枣地理解先前的介绍,会觉得因为磁盘高速缓存属于 File-backed 内存的一种,所以 File-backed 内存的总和即为“Active(file)+Inactive(file)”所对应的值。
但从实际计算的结果来看,如下所示,buffers+cached 的值要多出 23732KB。
1. buffers+cached = 457776KB
2. Active(file)+Inactive(file) = 434044KB
仔细观察图 1.22 就会发现,它们之间的差与⑦ Shmem 所表示的值刚好相同,这绝不是巧合。所谓的 Shmem,指的是 tmpfs 所使用的内存。
在这里先回顾一下 tmpfs。如同在 1.2.3 节中提到的,tmpfs 即利用物理内存来提供 RAM 磁盘功能。安装的 tmpfs 类型的文件系统和普通的文件系统一样,可以进行读写操作,但是文件的实体并不是物理磁盘,而是保存在服务器的物理内存上。
在 tmpfs 上保存文件时,文件系统会暂时将它们保存在磁盘高速缓存上。这便是 Shmem 的本质所在,它属于磁盘高速缓存所对应的“buffers+cached”一类。但是由于在物理磁盘上并没有与之对应的空间,因此它不属于 File-backed 内存对应的 LRU 列表,而是记录在匿名内存的 LRU 列表上。这就是为什么 File-backed 内存的总和与“buffers+cached”的值有所出入。
另外,由于匿名内存是换出的对象,因此 tmpfs 也成为了换出对象。使 tmpfs 成为换出对象的机制就是在这里设置的 22。
这里将 tmpfs 使用的内存称为 Shmem 是有原因的。在 Linux 中,为了实现“共享内存”(shared memory)功能,即多个进程共同使用同一内存中的内容,需要在内部使用 tmpfs。具体来说,就是通过文件映射功能,把在 tmpfs 中创建(物理内存上)的文件映射至多个进程的逻辑空间中。
出于这些原因,内核内部中 tmpfs 使用的内存便被称为“Shmem”(shared memory 的缩写)。内核源代码中,tmpfs 的功能也是通过一个名为 mm/shmem.c 的文件来实现的。
它们之间的关系,就如图 1.23 所示。虽然难免存在误差,但大体来说下面的关系式是成立的 23。
= Active(file) + Inactive(file) + Active(anon) + Inactive(anon) + Unevictable
= buffers + cached + AnonPages
最后,介绍一下图 1.23 右上部分所示的 AnonPages。
图 1.23 用户内存的分类
Linux 内核中存在着一个 rmap(reverse mapping)机制,负责管理匿名内存的每一个内存页,即“该页是被映射到哪个进程的哪个逻辑地址的”这类信息。这个 rmap 中记录的内存页的总和应与 AnonPages 相对应,也就是图 1.22 中⑧所表示的数值。
要让 Shmem 成为换出的对象,就要以匿名内存的形式记录到 LRU 列表中,但由于其具有文件系统的功能,因此无法记录在 rmap 里。这样一来,Shmem 的值(同时也是磁盘高速缓存和 File-backed 内存之间的差值)也就成了 AnonPages 和匿名内存之间的差值。
此外,之前介绍的 Unevictable 列表中所包含的页面,在图 1.23 中省略了。
内核内存的分类
从全部的内存使用量中减去用户内存的使用量,就是内核内存的使用量。图 1.22 中的① ~ ④加上 Unevictable 的和便是用户内存的使用量。从图中的注释可知,① ~ ④的和也可以通过“Active+Inactive”来计算。
因此,内核内存的使用量可以按如下方法计算。
内核内存 = MemTotal –(MemFree + Active + Inactive + Unevictable)
当然,仅凭这个公式也无法得知内核内存的具体细节。遗憾的是,并不是所有的内核内存信息都会输出到 /proc/meminfo 中,这里仅针对典型应用来说明如何确定其使用量。
首先,图 1.22 中的⑨ Slab 是由“Slab 分配器”分配的总量。内存空间存储着内核所使用的各种数据,但它并不是每次都寻找空闲空间来进行分配,而是事先汇总数据,在数据类型的基础上再进行分配,提供这一机制的便是 Slab 分配器。
之前说过,物理内存的分配和释放是以 4KB 大小的页为单位进行的。因此,在需要 4KB 以下内存的情况下,要将 4KB 的内存

我要回帖

更多关于 硬盘柱面损坏 的文章

 

随机推荐