写点什么

打通 IO 栈:一次编译服务器性能优化实战

用户头像
AI乔治
关注
发布于: 2020 年 10 月 15 日
打通IO栈:一次编译服务器性能优化实战

背景

随着企业 SDK 在多条产品线的广泛使用,随着 SDK 开发人员的增长,每日往 SDK 提交的补丁量与日俱增,自动化提交代码检查的压力已经明显超过了通用服务器的负载。于是向公司申请了一台专用服务器,用于 SDK 构建检查。

$ cat /proc/cpuinfo | grep ^proccessor | wc -l48$ free -h             total       used       free     shared    buffers     cachedMem:           47G        45G       1.6G        20M       7.7G        25G-/+ buffers/cache:        12G        35GSwap:           0B         0B         0B$ df文件系统                          容量  已用  可用 已用% 挂载点....../dev/sda1                          98G   14G   81G   15% //dev/vda1                         2.9T  1.8T  986G   65% /home复制代码
复制代码

这是 KVM 虚拟的服务器,提供了 CPU 48 线程,实际可用 47G 内存,磁盘空间约达到 3TB。

由于独享服务器所有资源,设置了十来个 worker 并行编译,从提交补丁到发送编译结果的速度杠杠的。但是在补丁提交非常多的时候,速度瞬间就慢了下去,一次提交触发的编译甚至要 1 个多小时。通过 top 看到 CPU 负载并不高,难道是 IO 瓶颈?找 IT 要到了 root 权限,干起来!

由于认知的局限性,如有考虑不周的地方,希望一起交流学习

整体认识 IO 栈

如果有完整的 IO 栈的认识,无疑有助于更细腻的优化 IO。循着 IO 栈从上往下的顺序,我们逐层分析可优化的地方。

在网上有 Linux 完整的 IO 栈结构图,但太过完整反而不容易理解。按我的认识,简化过后的 IO 栈应该是下图的模样。



  1. 用户空间:除了用户自己的 APP 之外,也隐含了所有的库,例如常见的 C 库。我们常用的 IO 函数,例如open()/read()/write()是系统调用,由内核直接提供功能实现,而fopen()/fread()/fwrite()则是 C 库实现的函数,通过封装系统调用实现更高级的功能。

  2. 虚拟文件系统:屏蔽具体文件系统的差异,向用户空间提供统一的入口。具体的文件系统通过register_filesystem()向虚拟文件系统注册挂载钩子,在用户挂载具体的文件系统时,通过回调挂载钩子实现文件系统的初始化。虚拟文件系统提供了inode来记录文件的元数据,dentry记录了目录项。对用户空间,虚拟文件系统注册了系统调用,例如SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)注册了open()的系统调用。

  3. 具体的文件系统:文件系统要实现存储空间的管理,换句话说,其规划了哪些空间存储了哪些文件的数据,就像一个个收纳盒,A 文件保存在这个块,B 文件则放在哪个块。不同的管理策略以及其提供的不同功能,造就了各式各样的文件系统。除了类似于 vfat、ext4、btrfs 等常见的块设备文件系统之外,还有 sysfs、procfs、pstorefs、tempfs 等构建在内存上的文件系统,也有 yaffs,ubifs 等构建在 Flash 上的文件系统。

  4. 页缓存:可以简单理解为一片存储着磁盘数据的内存,不过其内部是以页为管理单元,常见的页大小是 4K。这片内存的大小不是固定的,每有一笔新的数据,则申请一个新的内存页。由于内存的性能远大于磁盘,为了提高 IO 性能,我们就可以把 IO 数据缓存在内存,这样就可以在内存中获取要的数据,不需要经过磁盘读写的漫长的等待。申请内存来缓存数据简单,如何管理所有的页缓存以及如何及时回收缓存页才是精髓。

  5. 通用块层:通用块层也可以细分为bio层request层页缓存以页为管理单位,而bio则记录了磁盘块与页之间的关系,一个磁盘块可以关联到多个不同的内存页中,通过submit_bio()提交 bio 到request层。一个 request 可以理解为多个 bio 的集合,把多个地址连续的 bio 合并成一个 request。多个 request 经过 IO 调度算法的合并和排序,有序地往下层提交 IO 请求。

  6. 设备驱动与块设备:不同块设备有不同的使用协议,而特定的设备驱动则是实现了特定设备需要的协议以正常驱使设备。对块设备而言,块设备驱动需要把 request 解析成一个个设备操作指令,在协议的规范下与块设备通信来交换数据。

形象点来说,发起一次 IO 读请求的过程是怎么样的呢?

用户空间通过虚拟文件系统提供的统一的 IO 系统调用,从用户态切到内核态。虚拟文件系统通过调用具体文件系统注册的回调,把需求传递到具体的文件系统中。紧接着具体的文件系统根据自己的管理逻辑,换算到具体的磁盘块地址,从页缓存寻找块设备的缓存数据。读操作一般是同步的,如果在页缓存没有缓存数据,则向通用块层发起一次磁盘读。通用块层合并和排序所有进程产生的的 IO 请求,经过设备驱动块设备读取真正的数据。最后是逐层返回。读取的数据既拷贝到用户空间的 buffer 中,也会在页缓存中保留一份副本,以便下次快速访问。

如果 页缓存没命中,同步读会一路通到 块设备,而对于 异步写,则是把数据放到 页缓存后返回,由内核回刷进程在合适时候回刷到 块设备。

根据这个流程,考虑到我没要到 KVM host 的权限,我只能着手从 Guest 端的 IO 栈做优化,具体包括以下几个方面:

  1. 交换分区(swap)

  2. 文件系统(ext4)

  3. 页缓存(Page Cache)

  4. Request 层(IO 调度算法)

由于源码以及编译的临时文件都不大但数量极其多,对随机 IO 的要求非常高。要提高随机 IO 的性能,在不改变硬件的情况下,需要缓存更多数据,以实现合并更多的 IO 请求。

咨询 ITer 得知,服务器都有备用电源,能确保不会掉电停机。出于这样的情况,我们可以尽可能优化速度,而不用担心掉电导致数据丢失问题。

总的来说,优化的核心思路是尽可能多的使用内存缓存数据,尽可能减小不必要的开销,例如文件系统为了保证数据一致性使用日志造成的开销。

交换分区

交换分区的存在,可以让内核在内存压力大时,把内核认为一些不常用的内存置换到交换分区,以此腾出更多的内存给系统。在物理内存容量不足且运行吃内存的应用时,交换分区的作用效果是非常明显的。

然而本次优化的服务器反而不应该使用交换分区。为什么呢?服务器总内存达到 47G,且服务器除了 Jenkins slave 进程外没有大量吃内存的进程。从内存的使用情况来看,绝大部分内存都是被cache/buffer占用,是可丢弃的文件缓存,因此内存是充足的,不需要通过交换分区扩大虚拟内存。

# free -h             total       used       free     shared    buffers     cachedMem:           47G        45G       1.6G        21M        18G        16G-/+ buffers/cache:        10G        36G复制代码
复制代码

交换分区也是磁盘的空间,从交换分区置入置出数据可也是要占用 IO 资源的,与本次 IO 优化目的相悖,因此在此服务器中,需要取消 swap 分区

查看系统状态发现,此服务器并没使能 swap。

# cat /proc/swapsFilename        Type    Size  Used  Priority#复制代码
复制代码

文件系统

用户发起一次读写,经过了虚拟文件系统(VFS)后,交给了实际的文件系统。

首先查询分区挂载情况:

# mount.../dev/sda1 on on / type ext4 (rw)/dev/vda1 on /home type ext4 (rw)...复制代码
复制代码

此服务器主要有两个块设备,分别是 sda 和 vda。sda 是常见的 SCSI/IDE 设备,我们个人 PC 上如果使用的机械硬盘,往往就会是 sda 设备节点。vda 是 virtio 磁盘设备。由于本服务器是 KVM 提供的虚拟机,不管是 sda 还是 vda,其实都是虚拟设备,差别在于前者是完全虚拟化的块设备,后者是半虚拟化的块设备。从网上找到的资料来看,使用半虚拟化的设备,可以实现 Host 与 Guest 更高效的协作,从而实现更高的性能。在此例子中,sda 作为根文件系统使用,vda 则是用于存储用户数据,在编译时,主要看得是 vda 分区的 IO 情况。

vda 使用 ext4 文件系统。ext4 是目前常见的 Linux 上使用的稳定的文件系统,查看其超级块信息:

# dumpe2fs /dev/vda1...Filesystem features:      has_journal dir_index ......Inode count:              196608000Block count:              786431991Free inodes:              145220571Block size:               4096...复制代码
复制代码

我猜测 ITer 使用的默认参数格式化的分区,为其分配了块大小为 4K,inode 数量达到 19660 万个且使能了日志。

块大小设为 4K 无可厚非,适用于当前源文件偏小的情况,也没必要为了更紧凑的空间降低块大小。空闲 inode 达到 14522 万,空闲占比达到 73.86%。当前 74% 的空间使用率,inode 只使用了 26.14%。一个 inode 占 256B,那么 10000 万个 inode 占用 23.84G。inode 实在太多了,造成大量的空间浪费。可惜,inode 数量在格式化时指定,后期无法修改,当前也不能简单粗暴地重新格式化。

我们能做什么呢?我们可以从日志挂载参数着手优化

日志是为了保证掉电时文件系统的一致性,(ordered 日志模式下)通过把元数据写入到日志块,在写入数据后再修改元数据。如果此时掉电,通过日志记录可以回滚文件系统到上一个一致性的状态,即保证元数据与数据是匹配的。然而上文有说,此服务器有备用电源,不需要担心掉电,因此完全可以把日志取消掉。

# tune2fs -O ^has_journal /dev/vda1tune2fs 1.42.9 (4-Feb-2014)The has_journal feature may only be cleared when the filesystem isunmounted or mounted read-only.复制代码
复制代码

可惜失败了。由于时刻有任务在执行,不太好直接umount或者-o remount,ro,无法在挂载时取消日志。既然取消不了,咱们就让日志最少损耗,就需要修改挂载参数了。

ext4 挂载参数: data

ext4 有 3 种日志模式,分别是orderedwritebackjournal。他们的差别网上有很多资料,我简单介绍下:

  1. jorunal:把元数据与数据一并写入到日志块。性能差不多折半,因为数据写了两次,但最安全

  2. writeback: 把元数据写入日志块,数据不写入日志块,但不保证数据先落盘。性能最高,但由于不保证元数据与数据的顺序,也是掉电最不安全的

  3. ordered:与 writeback 相似,但会保证数据先落盘,再是元数据。折中性能以保证足够的安全,这是大多数 PC 上推荐的默认的模式

在不需要担心掉电的服务器环境,我们完全可以使用 writeback 的日志模式,以获取最高的性能。

# mount -o remount,rw,data=writeback /homemount: /home not mounted or bad option# dmesg[235737.532630] EXT4-fs (vda1): Cannot change data mode on remount复制代码
复制代码

沮丧,又是不能动态改,干脆写入到/etc/config,只能寄希望于下次重启了。

# cat /etc/fstabUUID=...  /home  ext4  defaults,rw,data=writeback...复制代码
复制代码

ext4 挂载参数:noatime

Linux 上对每个文件都记录了 3 个时间戳

时间戳全称含义 atimeaccess time 访问时间,就是最近一次读的时间 mtimedata modified time 数据修改时间,就是内容最后一次改动时间 ctimestatus change time 文件状态(元数据)的改变时间,比如权限,所有者等

我们编译执行的Make可以根据修改时间来判断是否要重新编译,而atime记录的访问时间其实在很多场景下都是多余的。所以,noatime应运而生。不记录atime可以大量减少读造成的元数据写入量,而元数据的写入往往产生大量的随机 IO。

# mount -o ...noatime... /home复制代码
复制代码

ext4 挂载参数:nobarrier

这主要是决定在日志代码中是否使用写屏障(write barrier),对日志提交进行正确的磁盘排序,使易失性磁盘写缓存可以安全使用,但会带来一些性能损失。从功能来看,跟writebackordered日志模式非常相似。没研究过这方面的源码,说不定就是一回事。不管怎么样,禁用写屏障毫无疑问能提高写性能。

# mount -o ...nobarrier... /home复制代码
复制代码

ext4 挂载参数:delalloc

delalloc是 delayed allocation 的缩写,如果使能,则 ext4 会延缓申请数据块直至超时。为什么要延缓申请呢?在 inode 中采用多级索引的方式记录了文件数据所在的数据块编号,如果出现大文件,则会采用 extent 区段的形式,分配一片连续的块,inode 中只需要记录开始块号与长度即可,不需要索引记录所有的块。这除了减轻 inode 的压力之外,连续的块可以把随机写改为顺序写,加快写性能。连续的块也符合 局部性原理,在预读时可以加大命中概率,进而加快读性能。

# mount -o ...delalloc... /home复制代码
复制代码

ext4 挂载参数:inode_readahead_blks

ext4 从 inode 表中预读的 indoe block 最大数量。访问文件必须经过 inode 获取文件信息、数据块地址。如果需要访问的 inode 都在内存中命中,就不需要从磁盘中读取,毫无疑问能提高读性能。其默认值是 32,表示最大预读 32 × block_size 即 64K 的 inode 数据,在内存充足的情况下,我们毫无疑问可以进一步扩大,让其预读更多。

# mount -o ...inode_readahead_blks=4096... /home复制代码
复制代码

ext4 挂载参数:journal_async_commit

commit 块可以不等待 descriptor 块,直接往磁盘写。这会加快日志的速度。

# mount -o ...journal_async_commit... /home复制代码
复制代码

ext4 挂载参数:commit

ext4 一次缓存多少秒的数据。默认值是 5,表示如果此时掉电,你最多丢失 5s 的数据量。设置更大的数据,就可以缓存更多的数据,相对的掉电也有可能丢失更多的数据。在此服务器不怕掉电的情况,把数值加大可以提高性能。

# mount -o ...commit=1000... /home复制代码
复制代码

ext4 挂载参数汇总

最终在不能 umount 情况下,我执行的调整挂载参数的命令为:

mount -o remount,rw,noatime,nobarrier,delalloc,inode_readahead_blks=4096,journal_async_commit,commit=1800  /home复制代码
复制代码

此外,在/etc/fstab 中也对应修改过来,避免重启后优化丢失

# cat /etc/fstabUUID=...  /home  ext4  defaults,rw,noatime,nobarrier,delalloc,inode_readahead_blks=4096,journal_async_commit,commit=1800,data=writeback 0 0...复制代码
复制代码

页缓存

页缓存在 FS 与通用块层之间,其实也可以归到通用块层中。为了提高 IO 性能,减少真实的从磁盘读写的次数,Linux 内核设计了一层内存缓存,把磁盘数据缓存到内存中。由于内存以 4K 大小的 为单位管理,磁盘数据也以页为单位缓存,因此也称为页缓存。在每个缓存页中,都包含了部分磁盘信息的副本。

如果因为之前读写过或者被预读加载进来,要读取数据刚好在缓存中命中,就可以直接从缓存中读取,不需要深入到磁盘。不管是同步写还是异步写,都会把数据 copy 到缓存,差别在于异步写只是 copy 且把页标识脏后直接返回,而同步写还会调用类似fsync()的操作等待回写,详细可以看内核函数generic_file_write_iter()。异步写产生的脏数据会在“合适”的时候被内核工作队列writeback进程回刷。

那么,什么时候是合适的时候呢?最多能缓存多少数据呢?对此次优化的服务器而言,毫无疑问延迟回刷可以在频繁的删改文件中减少写磁盘次数,缓存更多的数据可以更容易合并随机 IO 请求,有助于提升性能。

/proc/sys/vm中有以下文件与回刷脏数据密切相关:

配置文件功能默认值 dirty_background_ratio 触发回刷的脏数据占可用内存的百分比 0dirty_background_bytes 触发回刷的脏数据量 10dirty_bytes 触发同步写的脏数据量 0dirty_ratio 触发同步写的脏数据占可用内存的百分比 20dirty_expire_centisecs 脏数据超时回刷时间(单位:1/100s)3000dirty_writeback_centisecs 回刷进程定时唤醒时间(单位:1/100s)500

对上述的配置文件,有几点要补充的:

  1. XXX_ratio 和 XXX_bytes 是同一个配置属性的不同计算方法,优先级 XXX_bytes > XXX_ratio

  2. 可用内存并不是系统所有内存,而是 free pages + reclaimable pages

  3. 脏数据超时表示内存中数据标识脏一定时间后,下次回刷进程工作时就必须回刷

  4. 回刷进程既会定时唤醒,也会在脏数据过多时被动唤醒。

dirty_background_XXX 与 dirty_XXX 的差别在于前者只是唤醒回刷进程,此时应用依然可以异步写数据到 Cache,当脏数据比例继续增加,触发 dirty_XXX 的条件,不再支持应用异步写。

更完整的功能介绍,可以看内核文档Documentation/sysctl/vm.txt,也可看我写的一篇总结博客《Linux 脏数据回刷参数与调优》

对当前的案例而言,我的配置如下:

dirty_background_ratio = 60dirty_ratio = 80dirty_writeback_centisecs = 6000dirty_expire_centisecs = 12000复制代码
复制代码

这样的配置有以下特点:

  1. 当脏数据达到可用内存的 60%时唤醒回刷进程

  2. 当脏数据达到可用内存的 80%时,应用每一笔数据都必须同步等待

  3. 每隔 60s 唤醒一次回刷进程

  4. 内存中脏数据存在时间超过 120s 则在下一次唤醒时回刷

当然,为了避免重启后丢失优化结果,我们在/etc/sysctl.conf中写入:

# cat /etc/sysctl.conf...vm.dirty_background_ratio = 60vm.dirty_ratio = 80vm.dirty_expire_centisecs = 12000vm.dirty_writeback_centisecs = 6000复制代码
复制代码

Request 层

在异步写的场景中,当脏页达到一定比例,就需要通过通用块层把页缓存里的数据回刷到磁盘中。bio 层记录了磁盘块与内存页之间的关系,在 request 层把多个物理块连续的 bio 合并成一个 request,然后根据特定的 IO 调度算法对系统内所有进程产生的 IO 请求进行合并、排序。那么都有什么 IO 调度算法呢?

网上检索 IO 调度算法,大量的资料都在描述 Deadline,CFQ,NOOP 这 3 种调度算法,却没有备注这只是单队列上适用的调度算法。在最新的代码上(我分析的代码版本为 5.7.0),已经完全切换到 multi-queue 的新架构上了,支持的 IO 调度算法就成了 mq-deadline,BFQ,Kyber,none。

关于不同 IO 调度算法的优劣,网上有非常多的资料,本文不再累述。

在《Linux-storage-stack-diagram_v4.10》 对 Block Layer 的描述可以形象阐述单队列与多队列的差异。




单队列的架构,一个块设备只有一个全局队列,所有请求都要往这个队列里面塞,这在多核高并发的情况下,尤其像服务器动则 32 个核的情况下,为了保证互斥而加的锁就导致了非常大的开销。此外,如果磁盘支持多队列并行处理,单队列的模型不能充分发挥其优越的性能。

多队列的架构下,创建了 Software queues 和 Hardware dispatch queues 两级队列。Software queues 是每个 CPU core 一个队列,且在其中实现 IO 调度。由于每个 CPU 一个单独队列,因此不存在锁竞争问题。Hardware Dispatch Queues 的数量跟硬件情况有关,每个磁盘一个队列,如果磁盘支持并行 N 个队列,则也会创建 N 个队列。在 IO 请求从 Software queues 提交到 Hardware Dispatch Queues 的过程中是需要加锁的。理论上,多队列的架构的效率最差也只是跟单队列架构持平。

咱们回到当前待优化的服务器,当前使用的是什么 IO 调度器呢?

# cat /sys/block/vda/queue/schedulernone# cat /sys/block/sda/queue/schedulernoop [deadline] cfq复制代码
复制代码

这服务器的内核版本是

# uname -r3.13.0-170-generic复制代码
复制代码

查看 Linux 内核 git 提交记录,发现在 3.13.0 的内核版本上还没有实现适用于多队列的 IO 调度算法,且此时还没完全切到多队列架构,因此使用单队列的 sda 设备依然存在传统的 noop,deadline 和 cfq 调度算法,而使用多队列的 vda 设备(virtio)的 IO 调度算法只有 none。为了使用 mq-deadline 调度算法把内核升级的风险似乎很大。因此 IO 调度算法方面没太多可优化的。

但 Request 层优化只能这样了?既然 IO 调度算法无法优化,我们是否可以修改 queue 相关的参数?例如加大 Request 队列的长度,加大预读的数据量。

在/sys/block/vda/queue 中有两个可写的文件 nr_requests 和 read_ahead_kb,前者是配置块层最大可以申请的 request 数量,后者是预读最大的数据量。默认情况下,

nr_request = 128read_ahead_kb = 128复制代码
复制代码

我扩大为

nr_request = 1024read_ahead_kb = 512复制代码
复制代码

优化效果

优化后,在满负荷的情况下,查看内存使用情况:

# cat /proc/meminfoMemTotal:       49459060 kBMemFree:         1233512 kBBuffers:        12643752 kBCached:         21447280 kBActive:         19860928 kBInactive:       16930904 kBActive(anon):    2704008 kBInactive(anon):    19004 kBActive(file):   17156920 kBInactive(file): 16911900 kB...Dirty:           7437540 kBWriteback:          1456 kB复制代码
复制代码

可以看到,文件相关内存(Active(file) + Inactive(file) )达到了 32.49GB,脏数据达到 7.09GB。脏数据量比预期要少,远没达到dirty_background_ratiodirty_ratio设置的阈值。因此,如果需要缓存更多的写数据,只能延长定时唤醒回刷的时间dirty_writeback_centisecs。这个服务器主要用于编译 SDK,读的需求远大于写,因此缓存更多的脏数据没太大意义。

我还发现 Buffers 达到了 12G,应该是 ext4 的 inode 占用了大量的缓存。如上分析的,此服务器的 ext4 有大量富余的 inode,在缓存的元数据里,无效的 inode 不知道占比多少。减少 inode 数量,提高 inode 利用率,说不定可以提高 inode 预读的命中率。

优化后,一次使能 8 个 SDK 并行编译,走完一次完整的编译流程(包括更新代码,抓取提交,编译内核,编译 SDK 等),在没有进入错误处理流程的情况下,用时大概 13 分钟。

这次的优化就到这里结束了,等后期使用过程如果还有问题再做调整。

看完三件事❤️

如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:

  1. 点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。

  2. 关注公众号 『 java 烂猪皮 』,不定期分享原创知识。

  3. 同时可以期待后续文章 ing🚀



本文来自公众号:Linux 阅码场,作者:廖威雄,就职于珠海全志科技股份有限公司,负责 Linux IO 全栈研发、性能优化、开源社区开发交流、Linux 内核开源社区 pstore/blk,mtdpstore 模块的作者(与 maintainer 交流中)、大客户存储技术支持、全志首个 UBI 存储方案主导人、全志首个 RTOS NFTL 主导人。


用户头像

AI乔治

关注

分享后端技术干货。公众号【 Java烂猪皮】 2019.06.30 加入

一名默默无闻的扫地僧!

评论

发布
暂无评论
打通IO栈:一次编译服务器性能优化实战