在計算機的世界里,我們可以將業務進行抽象簡化為兩種場景——計算密集型和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)中進行處理。
在Linux中一切皆文件,它提供了虛擬文件系統VFS的機制,用來抽象各種資源,使應用程序無需關心底層細節,只需通過open、read/write、close這幾個通用接口便可以管理各種不同的資源。不同的文件系統通過實現各自的通用接口來滿足不同的功能。
掛載在/dev目錄,devtmpfs中的文件代表各種設備。因此,對devtmpfs文件的讀寫操作,就是直接對相應設備的操作。
如果應用程序打開的是一個塊設備文件,則說明它直接對一個塊設備進行讀寫,調用塊設備的write函數:
const struct file_operations def_blk_fops = { .open = blkdev_open, ... ... .read = do_sync_read, .write = do_sync_write, ... ... };
這是我們最為熟悉的文件系統類型,它的文件就是我們一般理解的文件,對應實際磁盤中按照特定格式組織并管理的區域。對這類文件的讀寫操作,都會按照固定規則轉化為對應磁盤的讀寫操作。
應用程序如果打開的是一個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/queue scheduler #調度器配置 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);
... ...
}
軟中斷
每個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相關問題的時候,可以更加快速地找到關鍵部分,并深入研究解決。
服務電話: 400-678-1800 (周??周五 09:00-18:00)
商務合作: 0571-87770835
市場反饋: marketing@woqutech.com
地址: 杭州市濱江區濱安路1190號智匯中?A座1101室