技術乾貨 | 漫遊Linux塊IO

沃趣科技發表於2022-09-28

前言

在計算機的世界裡,我們可以將業務進行抽象簡化為兩種場景—— 計算密集型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/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);
    ... ...
}



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相關問題的時候,可以更加快速地找到關鍵部分,並深入研究解決。



來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/28218939/viewspace-2916509/,如需轉載,請註明出處,否則將追究法律責任。

相關文章