技術干貨 | 漫游Linux塊IO

2022-12-12 15:18:31 來源:51CTO博客

前言

在計算機的世界里,我們可以將業務進行抽象簡化為兩種場景——計算密集型IO密集型。這兩種場景下的表現,決定這一個計算機系統的能力。數據庫作為一個典型的基礎軟件,它的所有業務邏輯同樣可以抽象為這兩種場景的混合。因此,一個數據庫系統性能的強悍與否,往往跟操作系統和硬件提供的計算能力、IO能力緊密相關。


(資料圖片僅供參考)

除了硬件本身的物理極限,操作系統在軟件層面的處理以及提供的相關機制也尤為重要。因此,想要數據庫發揮更加極限的性能,對操作系統內部相關機制和流程的理解就很重要。

本篇文章,我們就一起看下Linux中一個IO請求的生命周期。Linux發展到今天,其內部的IO子系統已經相當復雜。每個點展開都能自成一篇,所以本次僅是對塊設備的寫IO做一個快速的漫游,后續再對相關專題進行詳細分解。

??

從用戶態程序出發

首先需要明確的是,什么是塊設備?我們知道IO設備可以分為字符設備和塊設備,字符設備以字節流的方式訪問數據,比如我們的鍵盤鼠標。而塊設備則是以塊為單位訪問數據,并且支持隨機訪問,典型的塊設備就是我們常見的機械硬盤和固態硬盤。

一個應用程序想將數據寫入磁盤,需要通過系統調用來完成:open打開文件 ---> write寫入文件 ---> close關閉文件。

下面是write系統調用的定義,我們可以看到,應用程序只需要指定三個參數:

1. 想要寫入的文件

2. 寫入數據所在的內存地址

3. 寫入數據的長度

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,    size_t, count){  struct fd f = fdget_pos(fd);  ssize_t ret = -EBADF;  if (f.file) {    loff_t pos = file_pos_read(f.file);    ret = vfs_write(f.file, buf, count, &pos);    if (ret >= 0)      file_pos_write(f.file, pos);    fdput_pos(f);  }  return ret;}

而剩下的工作就進入到內核中的虛擬文件系統(VFS)中進行處理。

虛擬文件系統(VFS)

在Linux中一切皆文件,它提供了虛擬文件系統VFS的機制,用來抽象各種資源,使應用程序無需關心底層細節,只需通過open、read/write、close這幾個通用接口便可以管理各種不同的資源。不同的文件系統通過實現各自的通用接口來滿足不同的功能。

devtmpfs

掛載在/dev目錄,devtmpfs中的文件代表各種設備。因此,對devtmpfs文件的讀寫操作,就是直接對相應設備的操作。

如果應用程序打開的是一個塊設備文件,則說明它直接對一個塊設備進行讀寫,調用塊設備的write函數:

const struct file_operations def_blk_fops = {  .open    = blkdev_open,    ... ...  .read    = do_sync_read,  .write    = do_sync_write,    ... ...};

磁盤文件系統(ext4等)

這是我們最為熟悉的文件系統類型,它的文件就是我們一般理解的文件,對應實際磁盤中按照特定格式組織并管理的區域。對這類文件的讀寫操作,都會按照固定規則轉化為對應磁盤的讀寫操作。

應用程序如果打開的是一個ext4文件系統的文件,則會調用ext4的write函數:

const struct file_operations_extend  ext4_file_operations = {  .kabi_fops = {    ... ...    .read    = do_sync_read,    .write    = do_sync_write,    ... ...    .open    = ext4_file_open,    ... ...};

buffer/cache

Linux提供了緩存來提高IO的性能,無論打開的是設備文件還是磁盤文件,一般情況IO會先寫入到系統緩存中并直接返回,IO生命周期結束。后續系統刷新緩存或者主動調用sync,數據才會被真正寫入到塊設備中。有意思的是,針對塊設備的稱為buffer,針對磁盤文件的稱為cache。

ssize_t __generic_file_aio_write(struct kiocb *iocb, const struct iovec *iov,         unsigned long nr_segs, loff_t *ppos)    ... ...  if (io_is_direct(file)) {    ... ...    written = generic_file_direct_write(iocb, iov, &nr_segs, pos,              ppos, count, ocount);    ... ...  } else {    written = generic_file_buffered_write(iocb, iov, nr_segs,        pos, ppos, count, written);  }    ... ...

Direct IO

當打開文件時候指定了O_DIRECT標志,則指定文件的IO為direct IO,它會繞過系統緩存直接發送給塊設備。在發送給塊設備之前,虛擬文件系統會將write函數參數表示的IO轉化為dio,在其中封裝了一個個bio結構,接著調用submit_bio將這些bio提交到通用塊層進行處理。

do_blockdev_direct_IO     -> dio_bio_submit       -> submit_bio

通用塊層

核心結構

1.bio/request

bio是Linux通用塊層和底層驅動的IO基本單位,可以看到它的最重要的幾個屬性,一個bio就可以表示一個完整的IO操作:???????
struct bio {  sector_t    bi_sector; //io的起始扇區... ...  struct block_device  *bi_bdev;  //對應的塊設備... ...  bio_end_io_t    *bi_end_io;  //io結束的回調函數... ...  struct bio_vec    *bi_io_vec;  //內存page列表... ...};
request代表一個獨立的IO請求,是通用塊層和驅動層進行IO傳遞的結構,它容納了一組連續的bio。通用塊層提供了很多IO調度策略,將多個bio合并生成一個request,以提高IO的效率。

2.gendisk

每個塊設備都對應一個gendisk結構,它定義了設備名、主次設備號、請求隊列,和設備的相關操作函數。通過add_disk,我們就真正在系統中定義一個塊設備。

3.request_queue

這個即是日常所說的IO請求隊列,通用塊層將IO轉化為request并插入到request_queue中,隨后底層驅動從中取出完成后續IO處理。

struct request_queue {  ... ...  struct elevator_queue  *elevator;  //調度器  request_fn_proc    *request_fn;  //請求處理函數  make_request_fn    *make_request_fn;  //請求入隊函數  ... ...  softirq_done_fn    *softirq_done_fn;  //軟中斷處理  struct device    *dev;  unsigned long    nr_requests;  ... ...};

處理流程

在收到上層文件系統提交的bio后,通用塊層最主要的功能就是根據bio創建request,并插入到request_queue中。

在這個過程中會對bio進行一系列處理:當bio長度超過限制會被分割,當bio訪問地址相鄰則會被合并。

request創建后,根據request_queue配置的不同elevator調度器,request插入到對應調度器隊列中。在底層設備驅動程序從request_queue取出request處理時,不同elevator調度器返回request策略不同,從而實現對request的調度。

void blk_queue_bio(struct request_queue *q, struct bio *bio){    ... ...  el_ret = elv_merge(q, &req, bio);    //嘗試將bio合并到已有的request中  ... ...  req = get_request(q, rw_flags, bio, 0);  //無法合并,申請新的request    ... ...  init_request_from_bio(req, bio);}void blk_flush_plug_list(struct blk_plug *plug, bool from_schedule){    ... ...      __elv_add_request(q, rq, ELEVATOR_INSERT_SORT_MERGE);  //將request插入request_queue的elevator調度器    ... ...}

請求隊列

Linux中提供了不同類型的request_queue,一個是本文主要涉及的single-queue,另外一個是multi-queue。single-queue是在早期的硬件設備(例如機械硬盤)只能串行處理IO的背景下創建的,而隨著更快速的SSD設備的普及,single-queue已經無法發揮底層存儲的性能了,進而誕生了multi-queue,它優化了很多機制使IOPS達到了百萬級別以上。至于multi-queue和single-queue的詳細區別,本篇不做討論。

每個隊列都可以配置不同的調度器,常見的有noop、deadline、cfq等。不同的調度器會根據IO類型、進程優先級、deadline等因素,對request請求進一步進行合并和排序。我們可以通過sysfs進行配置,來滿足業務場景的需求:

#/sys/block/sdx/queuescheduler      #調度器配置nr_requests      #隊列深度max_sectors_kb    #最大IO大小

設備驅動

在IO經過通用塊層的處理和調度后,就進入到了設備驅動層,就開始需要和存儲硬件進行交互。

以scsi驅動為例:在scsi的request處理函數scsi_request_fn中,循環從request_queue中取request,并創建scsi_cmd下發給注冊到scsi子系統的設備驅動。需要注意的是,scsi_cmd中會注冊一個scsi_done的回調函數。

static void scsi_request_fn(struct request_queue *q){  for (;;) {    ... ...    req = blk_peek_request(q);    //從request_queue中取出request    ... ...        cmd->scsi_done = scsi_done;    //指定cmd完成后回調    rtn = scsi_dispatch_cmd(cmd);  //下發將request對應的scsi_cmd    ... ...  }}int scsi_dispatch_cmd(struct scsi_cmnd *cmd){  ... ...  rtn = host->hostt->queuecommand(host, cmd);    ... ...}

IO完成

軟中斷

每個request_queue都會注冊軟中斷號,用來進行IO完成后的下半部處理,scsi驅動中注冊的為:scsi_softirq_done

struct request_queue *scsi_alloc_queue(struct scsi_device *sdev){    ... ...  q = __scsi_alloc_queue(sdev->host, scsi_request_fn);    ... ...  blk_queue_softirq_done(q, scsi_softirq_done);  ... ...}

硬中斷

當存儲設備完成IO后,會通過硬件中斷通知設備驅動,此時設備驅動程序會調用scsi_done回調函數完成scsi_cmd,并最終觸發BLOCK_SOFTIRQ軟中斷。

void __blk_complete_request(struct request *req){      ... ...      raise_softirq_irqoff(BLOCK_SOFTIRQ);      ... ...}

而BLOCK_SOFTIRQ軟中斷的處理函數就是之前注冊的scsi_softirq_done,通過自下而上層層回調,到達bio_end_io,完成整個IO的生命周期。

-> scsi_finish_command      -> scsi_io_completion        -> scsi_end_request          -> blk_update_request            -> req_bio_endio              -> bio_endio

總結

以上,我們很粗略地漫游了Linux中一個塊設備IO的生命周期,這是一個很復雜的過程,其中很多機制和細節只是點到為止,但是我們有了對整個IO路徑的整體的認識。當我們再遇到IO相關問題的時候,可以更加快速地找到關鍵部分,并深入研究解決。

作者:沃趣科技原型研發部

標簽: 應用程序 文件系統 生命周期

上一篇:【世界熱聞】HTML基礎使用
下一篇:世界即時看!#yyds干貨盤點# react筆記之學習之props父子傳值