已经记不得从什么时候开始,我的 Linux 桌面偶尔会遇到无响应的问题,这个问题经常跟比较重的磁盘读写相关,例如更新 JetBrains IDE,JetBrains IDE 创建索引。之所以会把无响应的问题归结到磁盘读写上,是因为卡顿往往跟内存占用、CPU 使用率、进程数无关。卡顿的表现为:鼠标可以移动,但是 KDE 桌面包括状态栏无法更新;Kitty 可以创建新的 Tab,但是 Fish 启动会卡住;已有终端下虽然可以流畅输入命令,但是执行命令会卡住。这个周末因为成都一直在下雨,不太方便出门,决定来尝试解决卡顿问题。
我的电脑配置如下:

出现问题的固态硬盘型号是 KINGSTON SNVS1000G, SMART 信息如下:
# nvme smart-log /dev/nvme1
Smart Log for NVME device:nvme1 namespace-id:ffffffff
critical_warning : 0
temperature : 89 °F (305 K)
available_spare : 100%
available_spare_threshold : 10%
percentage_used : 31%
endurance group critical warning summary: 0
Data Units Read : 61665672 (31.57 TB)
Data Units Written : 171536497 (87.83 TB)
host_read_commands : 967548759
host_write_commands : 2052988968
controller_busy_time : 75703
power_cycles : 79989
power_on_hours : 15798
unsafe_shutdowns : 241
media_errors : 0
num_err_log_entries : 0
Warning Temperature Time : 0
Critical Composite Temperature Time : 0
Thermal Management T1 Trans Count : 0
Thermal Management T2 Trans Count : 0
Thermal Management T1 Total Time : 0
Thermal Management T2 Total Time : 0
在官网上查到这块固态硬盘的设计 TBW 是 240TB,目前写入了 87.83TB,按照这篇文章的估算方法,这块硬盘正值壮年,属于如果挂掉了会令人惋惜的状态。既然硬盘是健康的,那么就需要从软件的角度来排查问题了。
复现问题
根据我的日常使用经验,卡顿往往伴随高磁盘 IO,故可以使用硬盘跑分工具测试来复现问题:
fio --filename=/path/to/no_cow_test.fio --size=8GB --direct=1 --rw=randrw --bs=4k --ioengine=libaio --iodepth=256 --runtime=120 --numjobs=4 --time_based --group_reporting --name=iops-test-job --eta-newline=1
fio
运行时,可以通过 htop
的 IO Tab 看到有明显的磁盘读写,这时如果运行不太常用的命令,比如前端切图仔电脑上的 rustc --version
,就可以观察到明显的延迟。通过这种方式比较稳定地复现问题场景后,就可以继续接下来的排查了。
检查日志
journalctl -b -p err --no-pager
跟 dmsg -T
展示的错误日志通常对排查问题非常重要,但是在我的卡顿问题复现时却没有记录下任何错误信息。问题的排查一度陷入僵局,这时就得靠 Gemini 大师帮我排查一下有哪些设置会影响存储设备的性能了。
脏页
在 Linux 系统中,当应用程序向文件写入数据时,这些数据通常不会立即被写入到物理磁盘上。相反,它们首先被写入到内存中的一个缓冲区(称为「页缓存」或「page cache」)。这些已经修改但尚未写入磁盘的内存页就被称为「脏页」。
脏页机制可以有效提升磁盘性能,但为了防止脏页无限增长,我们可以设定 vm.dirty_ratio
参数限制脏页数据占总内存的百分比,更多介绍请看 Wiki。
对于我的配置而言,如果 dirty_ratio 过大,比如 20%,意味着脏页数据量最高达到 6GB,不仅会挤占其他应用的可用内存空间,还可能由于操作系统需要一次性写入大量数据造成卡顿。
但在我的测试下,将 vm.ditry_ratio
以及相关参数调小一些带来的效果并不明显,所以还得向其他的方向上挖掘。
IO 调度器
IO 调度器是 Linux 内核的一个组件,它的主要任务是优化对存储设备(如硬盘、SSD、NVMe)的读写请求顺序。
对于 SSD 设备来说,IO 调度器可以帮助确保每个进程都能获得合理的 IO 访问机会,避免某些进程被「饿死」,另外不同的调度算法会在数据吞吐量与请求延迟之间有不同的取舍,以适应不同的应用场景。
在默认情况下,Linux 不会为 NVME 设备设置 IO 调度器,它假设这类设备上的控制器有自己的优化机制,不需要额外的软件层面调度。我们可以通过下面的命令查看当前在使用的调度器(被方括号包围):
cat /sys/block/nvme1n1/queue/scheduler
[none] mq-deadline kyber bfq
然而在调度器的选择上,Gemini 跟 Arch Wiki 出现了分歧,Gemini 认为 NVME 以及 SSD 就应该选择 none
作为 io scheduler,但 Arch Wiki 认为 it depends
:
The best choice of scheduler depends on both the device and the exact nature of the workload. Also, the throughput in MB/s is not the only measure of performance: deadline or fairness deteriorate the overall throughput but may improve system responsiveness. Benchmarking may be useful to indicate each I/O scheduler performance.
考虑到我的日常使用场景是桌面应用,NVME 上自带的控制器完全不可能知道我正在使用的窗口应用,也就没法保障我主要使用场景下的响应速度,于是我决定采用 Pop_OS 的推荐,使用 Kyber 调度器。
重试问题场景后,卡顿的情况缓解了一些,但跟我的预期还有很大偏差,很多命令可能需要 10s 以上才能输出结果。于是又尝试了使用 BFQ scheduler,终于 rustc --version
可以在后台运行重 IO 程序的情况下在 1s 内输出结果了。使用下面的 udev rule 就可以持久化设置 IO 调度器。
# /etc/udev/rules.d/60-ioschedulers.rules
ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="bfq"
通过设置 IO 调度器很好的解决了我的问题,那么代价是什么呢?
I/O 调度器性能对比
相较于 none 调度器,其他的软件调度器都加入了额外的一层抽象,这势必会引入更多的开销,为了搞清楚降低延迟的开销,我进行了下面的测试。
fio --filename=/path/to/no_cow/test.fio --size=8GB --direct=1 --rw=randrw --bs=4k --ioengine=libaio --iodepth=256 --runtime=20 --numjobs=4 --time_based --group_reporting --name=iops-test-job --eta-newline=1
iops-test-job: (g=0): rw=randrw, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=libaio, iodepth=256
指标 | none(基准) | BFQ | kyber |
---|---|---|---|
读取 IOPS | 7,214 | 7,018 (-2.7%) | 7,141 (-1.0%) |
写入 IOPS | 7,235 | 7,039 (-2.7%) | 7,161 (-1.0%) |
总 IOPS | 14,449 | 14,057 (-2.7%) | 14,302 (-1.0%) |
读取带宽 | 28.2 MiB/s | 27.4 MiB/s (-2.8%) | 27.9 MiB/s (-1.1%) |
写入带宽 | 28.3 MiB/s | 27.5 MiB/s (-2.8%) | 28.0 MiB/s (-1.1%) |
延迟指标 | none(基准) | BFQ | kyber |
---|---|---|---|
读取平均延迟 | 70.5ms | 72.4ms (+2.7%) | 71.2ms (+1.0%) |
写入平均延迟 | 70.9ms | 72.9ms (+2.8%) | 71.7ms (+1.1%) |
50% 延迟 | 70.8ms | 71ms (+0.3%) | 71ms (+0.3%) |
95% 延迟 | 78ms | 89ms (+14.1%) | 81ms (+3.8%) |
99% 延迟 | 82ms | 114ms (+39.0%) | 91-92ms (+11%) |
99.9% 延迟 | 88ms | 132-133ms (+50.0%) | 128ms (+45%) |
可以看到在单个进程的读写测试下, BFQ 跟 kyber 在吞吐量跟延迟上的表现要略逊一筹。而这些开销给 Linux 桌面场景下带来的提升量化后又有多少呢?这需要设计一个更加复杂的跑分测试了,我们需要让一个延迟敏感应用跟一个高吞吐量应用同时运行,然后对比不同 IO 调度器如何平衡两者的硬盘操作:
# 低延迟小请求 (模拟交互式应用)
fio --name=latency_sensitive \
--filename=$TEST_DIR/latency_sensitive.fio \
--size=2GB --direct=1 --rw=randread \
--bs=4k --ioengine=libaio --iodepth=1 \
--runtime=$RUNTIME --time_based \
--group_reporting --output-format=json \
--output=/tmp/latency_sensitive.json &
# 高吞吐量请求 (模拟批处理)
fio --name=throughput_heavy \
--filename=$TEST_DIR/throughput_heavy.fio \
--size=4GB --direct=1 --rw=randread \
--bs=4k --ioengine=libaio --iodepth=512 \
--runtime=$RUNTIME --time_based \
--group_reporting --output-format=json \
--output=/tmp/throughput_heavy.json &
不同的 IO 调度器在我电脑上的实际测试结果如下:
调度器 | 延迟敏感应用 IOPS | 延迟敏感应用平均延迟 | 高吞吐应用 IOPS | IOPS 比率 |
---|---|---|---|---|
BFQ | 5,474 | 0.18 ms | 129,350 | 23.63 |
kyber | 4,611 | 0.22 ms | 138,904 | 30.12 |
none | 3,154 | 0.32 ms | 146,104 | 46.32 |
IOPS 比率:高吞吐应用获得的 IOPS 是延迟敏感应用的多少倍
通过上面的测试可以看到:
- BFQ 在公平性方面表现最佳,真正保护了延迟敏感应用
- None 调度器确实能提供最高的原始吞吐量,但以牺牲公平性为代价
- Kyber 提供了较好的平衡,但在极端公平性方面不如 BFQ
结论
正如 Arch Wiki 所说,如何选择 IO 调度器取决于你的使用场景跟工作负载,在我的工作电脑上,BFQ 才是那个能够让我这块中年硬盘提供最佳桌面应用使用体验的调度器。
发表回复