Linux IO請求處理流程 (2) — 蓄流和泄流

Linux IO請求處理流程 (2) — 蓄流和泄流

來自專欄 Linux I/O20 人贊了文章

說明

在請求處理流程(1)中我們分析了每個IO請求是如何從文件系統發出並進入到塊設備層,加入到塊設備調度隊列中,在這裡我們將仔細闡述每個IO請求如何從塊設備的請求隊列被下發至更底層處理。

數據結構

與塊設備層IO相關的主要數據結構有2:

BIOstruct bio { sector_t bi_sector; struct bio *bi_next; /* request queue link */ struct block_device *bi_bdev; unsigned long bi_flags; /* status,command,etc */ unsigned long bi_rw; unsigned short bi_vcnt; /* how many bio_vecs */ unsigned short bi_idx; unsigned int bi_phys_segments; ...... // bio完成時的回調函數 bio_end_io_t *bi_end_io; void *bi_private; bio_destructor_t *bi_destructor; struct bio_vec bi_inline_vecs[0]; };REQUESTstruct request { struct list_head queuelist; struct call_single_data csd; struct request_queue *q; unsigned int cmd_flags; enum rq_cmd_type_bits cmd_type; unsigned long atomic_flags; ......}

將linux io相關最重要的兩個數據結構列在這裡,不作過多分析,都很簡單。以後需要再仔細分析吧。

蓄流和泄流

在前面我們分析的__make_request最後,當一切都準備妥當之後,每個bio請求要麼被merge到現有的某個request中,要麼被創建一個新的request添加到設備的request_queue中。

接下來,開始泄流。所謂的泄流,其實也就是將request_queue中的請求開始發往底層設備去處理。塊設備層不會來一個請求就發往下層去處理,而是蓄流至某個時刻,發送一批至底層處理,這樣可能會達到更好的batch效果,顯而易見。

我們首先來看看塊設備層泄流的時機。

關於Linux「蓄流」和「泄流」的時機,Linux存儲技術原理分析裡面寫的非常明確,以下摘自:

Linux塊IO層為請求隊列的「蓄流」和「泄流」分別提供了一系列的公共函數,如blk_plug_deviceblk_unplug_device

blk_plug_device函數為塊設備,或更精確地說,是塊設備的請求隊列進行「蓄流」。它設置queue_flags域的QUEUE_FLAG_PLUGGED標誌,然後啟動運行在unplug_timer域中的「泄流」定時器。

在調用blk_plug_device函數「蓄流」塊設備的請求隊列時,更新泄流時間為當前時間後的某個時間,具體值取決於隊列描述符的unplug_delay域的值,在blk_queue_make_request中被設置為3ms。

超時處理函數在blk_queue_make_request中被設置為blk_unplug_timeout。在這個函數中,最終調用了請求隊列的unplug_fn方法,通常被實例化為generic_unplug_device函數,參見blk_init_queue_node函數。

generic_unplug_device負責泄流塊設備:首先,它檢查請求隊列是否還處在活躍狀態:然後,清除請求隊列的蓄流標誌,刪除蓄流定時器,最後,執行request_fn開始處理隊列中的下一個請求。

此外,如果隊列中等待處理的請求書超過請求隊列描述符的unplug_thresh的值(默認是4),則IO調度器也會開始為請求隊列泄流。在elv_insert(被__evl_add_request 調用)函數中,如果出現這種情況,會調用__generic_unplug_device函數

綜上所述,泄流的時機有2:

1. 定時器超時了,開始泄流,這是應對請求較少場景下request不會被餓死;

2. 請求隊列中的請求數超過一定量(4),開始泄流,這是為了應對突發請求量較大的場景。

那什麼時候開始蓄流呢?

1. 第一次向該設備提交請求時要蓄流,因為此時該設備的request_queue是空的;

2. 如果泄流完成或者泄流過程中發現底層設備已經疲於應付(發送請求返回錯誤了),主動退出泄流模式,進入蓄流狀態。這是非常合理的,因為底層設備有處理能力限制,而且上層是非同步發送,我們不能不管底層設備的死活。

如果在泄流的時候上層又下發了bio,怎麼辦呢?等泄流完成嗎?

底層泄流的過程其實是很快的,因為每個請求發下去並給它一個回調函數就可以了,無需等著它完成。而且在泄流過程中,在從request_queue中獲取到一個request後,就會解除request_queue的lock,這也就意味著文件系統可以向該塊設備層繼續提交新請求。

接下來,讓我們仔細研究下是如何實現的:

// 如果QUEUE_PLUGGED被設置,應該蓄流static inline bool queue_should_plug(struct request_queue *q){ return !(blk_queue_nonrot(q) && blk_queue_tagged(q));}static int __make_request(struct request_queue *q, struct bio *bio){ ...... spin_lock_irq(q->queue_lock); ......out: // 如果是request_queue不應該蓄流了,此 // 時開始泄流,在此之前,已經lock了該 // queue if (unplug || ! queue_should_plug (q)) __generic_unplug_device(q); spin_unlock_irq(q->queue_lock); return 0;}/** remove the plug and let it rip..* Note: this is under q->lock*/void __generic_unplug_device(struct request_queue *q){ if (unlikely(blk_queue_stopped(q))) return; // if queue is un-plugging, return if (!blk_remove_plug(q) && !blk_queue_nonrot(q)) return; // init to scsi_request_fn(): q->request_fn(q);}

scsi_request_fn()函數由於篇幅原因,就不貼了,分析該函數的代碼,總結其處理流程:

  1. 請注意:進入函數時已經保證request_queue已經被lock;
  2. request_queue中取得一個request(blk_peek_request &blk_start_request),並解除request_queue的lock,不要耽誤了其他人提交bio。另外這裡取出請求的時候其實已經為該請求初始化好了對應的scsi_cmd;
  3. 各種錯誤判斷:底層設備是否準備好,沒有的話,轉步驟5,退出;
  4. 向底層設備發送scsi_cmd(scsi_dispatch_cmd);
  5. 判斷3返回值,如果出錯,蓄流(blk_plug_device),轉步驟6;如果未出錯轉步驟1繼續處理,繼續之前先對request_queue加鎖;
  6. 進入這裡,表明設備尚未準備好,我們暫時還不能向其發請求,那我們只能蓄流(blk_plug_device),注意這裡先得對request_queue加鎖;
  7. 準備清理環境,退出,先解鎖queue->lock,原因可見代碼注釋),清理完成再加鎖,之所以這樣是為了語義清晰:該函數進來的時候是加鎖的,出去的時候也是加鎖的,那麼調用者用起來自然也就清爽了。

仔細想想,這裡的邏輯也不算難理解,但是這裡可能會有以下問題是:

1. 加鎖解鎖的地方太多,很容易影響性能,雖然用的是自旋鎖。且整個地方只有一把大鎖,你懂的;

2. 如果在泄流的過程中上層(文件系統)源源不斷地發送請求的話,可能達不到蓄流的效果,上層提交的過快,而泄流線程可能沒那麼快,導致的結果就是來一個request就泄掉,再來一個還是泄掉,前後request無法做合併和排序,影響性能。

改進

好,那既然上面說到的蓄流泄流演算法存在種種的弊端,那我們如何改進?讓我們對症下藥,針對上面的癥結提出應對之策:

1. 化整為零:細化鎖粒度;

2. 批量提交:上層文件系統提交的時候不要一次一個來,太費事兒,一次給我來一打吧。

看,怎麼做其實是哲學問題,很簡單。那我們看看在新版內核(3.10)中是如何實現這兩點。

細化鎖粒度

新版內核中細化了鎖的粒度,除了request_queue全局有一把大鎖以外,每個進程增加了一個plug隊列,這樣,在極大程度上可以實現真正的並行了:當IO請求提交時,首先插入該隊列,在隊列漫時,再flush到設備的請求隊列request_queue中,這樣可避免頻繁對設備的請求隊列操作導致的鎖競爭,提升效率。

void blk_queue_bio(struct request_queue *q, struct bio *bio) { ...... /* * Check if we can merge with the plugged list before * grabbing any locks. */ // 嘗試向進程的plug list插入bio請求,甚至合併 if (attempt_plug_merge(q, bio, &request_count)) return; // 如果無法合併至進程的plug_list,只能乖乖插入request_queue了 ......}static bool attempt_plug_merge(struct request_queue *q, struct bio *bio, unsigned int *request_count){ struct blk_plug *plug; struct request *rq; bool ret = false; // 找到該進程的plug隊列 plug = current->plug; if (!plug) goto out; *request_count = 0; // 遍歷隊列的每個request,檢查bio是否可以合併至該request // 可合併條件: // 1. bio和request屬於同一個設備(queue一致) // 2. io請求連續 // 3. 合併後的request內IO請求大小未超過硬體限制 list_for_each_entry_reverse(rq, &plug->list, queuelist) { int el_ret; if (rq->q == q) (*request_count)++; // 如果bio和當前req無法合併,繼續遍歷下一個req if (rq->q != q || !blk_rq_merge_ok(rq, bio)) continue; // 嘗試merge el_ret = blk_try_merge(rq, bio); if (el_ret == ELEVATOR_BACK_MERGE) { ret = bio_attempt_back_merge(q, rq, bio); if (ret) break; } else if (el_ret == ELEVATOR_FRONT_MERGE) { ret = bio_attempt_front_merge(q, rq, bio); if (ret) break; } }out: return ret;}

通過上面的分析,我們確實看到了,每次提交bio的時候,都會將請求提交至當前進程的plug_list中。

批量提交

上面我們看到了,提交bio的時候將請求放在本地,等攢夠了再憋大招放下去。我們這裡就看看是如何做批量提交的。

上面判斷中如果bio無法合併至當前進程的plug_list中,且亦無法合併至request_queue中,那會為該bio創建一個新的request,然後將其插入到當前進程的request_queue的尾部,當然在插入之前還得判斷當前進程的plug_list中累積的request數量是否超過閾值,如果是,先將這些requests flush至request_queue中。

get_rq: ...... init_request_from_bio(req, bio); if (test_bit(QUEUE_FLAG_SAME_COMP, &q->queue_flags)) req->cpu = raw_smp_processor_id(); plug = current->plug; if (plug) { if (list_empty(&plug->list)) trace_block_plug(q); else { // 如果請求數量已經攢夠了,flush下去 // 第二個參數設置為false代表的是同步plug if (request_count >= BLK_MAX_REQUEST_COUNT) { blk_flush_plug_list(plug, false); trace_block_plug(q); } } // 刷完之後將該request添加到plug_list鏈表的尾部 list_add_tail(&req->queuelist, &plug->list); drive_stat_acct(req, 1); } else { spin_lock_irq(q->queue_lock); add_acct_request(q, req, where); __blk_run_queue(q); }

這裡判斷出如果本進程的plug_list的request已經攢得足夠多了,就調用blk_flush_plug_list()刷下去。注意調用該函數時將第二個參數設置為false,表示同步刷新,我們再後面將會看到它和一部刷新的區別。然後再將該request插入到plug_list的尾部。

我們接下來仔細研究下該是如何將plug_list的request flush到request_queue中的。

flush時機

同步flush

所謂的同步flush,也即我們上面剛分析的:在plug_list滿的時候,將該鏈表上的request一次性插入到request_queue中。這裡使用同步是因為我們必須等著flush完成才能繼續處理後面的request。

非同步flush

我們不能完全依賴同步flush,很簡單,因為如果在上層提交請求不足時可能會導致該list上的請求遲遲無法被調度。於是我們必須在一定的時候將這些request刷到設備的request_queue中。非同步flush發生在進程切換。

schedule-> sched_submit_work -> blk_schedule_flush_plug()-> blk_flush_plug_list(plug, true)

同步flush和非同步flush具體的區別在於調用blk_flush_plug_list()時設置的第二個參數區別:設置為false代表同步flush,否則代表非同步flush。

flush流程

void blk_flush_plug_list(struct blk_plug *plug, bool from_schedule){ // 為了效率考慮:將進程的plug_list請求全部拷貝到list內 // 不影響新的請求插入 list_splice_init(&plug->list, &list); // 對request進行排序: // 因為每個進程的plug_list可能包含多個設備的request // 排序規則: // 1. request_queue相同(發往同一個設備)的request放在一起 // 2. request_queue按照大小排序 // 排序結果: // 所有屬於同一個設備的request按照IO順序組織起來 list_sort(NULL, &list, plug_rq_cmp); local_irq_save(flags); // 對每一個request,判斷它與當前queue是否相同 // 如果不同,將當前queue進行unplug // 如果相同,則將該request插入到調度隊列 while (!list_empty(&list)) { rq = list_entry_rq(list.next); list_del_init(&rq->queuelist); BUG_ON(!rq->q); // 如果當前request_queue的request已經插入完了 // 那麼就將當前queue進行unplug,並更新當前queue為新的 // request所屬的queue if (rq->q != q) { // 為什麼這裡會觸發一次unplug? // 這裡不觸發的話沒別的地方觸發,這裡觸發提交的請求不一定夠多 if (q) queue_unplugged(q, depth, from_schedule); q = rq->q; spin_lock(q->queue_lock); } if (rq->cmd_flags & (REQ_FLUSH | REQ_FUA)) __elv_add_request(q, rq, ELEVATOR_INSERT_FLUSH); else __elv_add_request(q, rq, ELEVATOR_INSERT_SORT_MERGE); } if (q) // 為什麼這裡會觸發一次同步unplug? queue_unplugged(q, depth, from_schedule); local_irq_restore(flags);}

這個函數看起來也很簡單,主要流程如下:

  1. 取出plug_list上的單個request(當然是已經排序過的);
  2. 判斷該request是否和上一個request的queue一樣,即與上一個request是發往同一個設備。如果是,則繼續將該request插入到設備的request_queue中;否則:現將上次處理的request所屬的設備進行一次泄流(queue_unplugged(),且是同步泄流)
  3. 處理完所有的請求後,再將最後一次處理的request所屬的request_queue進行一次泄流

個人感覺,在這裡最不太好理解的就是為什麼要在這裡對設備進行泄流?

依我愚見:一般泄流的時機是request_queue中積攢了足夠的request,或者request_queue已經有一定時間未泄流了,這也正是我們前面分析老的內核的實現邏輯(請仔細回顧下)。但在新內核中拋棄了這種做法,或者說換了種做法,讓我們不妨考慮下:

如果一個進程的plug_list開始flush request了,說明必然是積攢了足夠多的request(雖然這些request不一定是屬於相同磁碟設備),但由於每個request可能可能是由很多bio merge而成。那也就意味著上層發下來的bio會更多。

因此,我們在flush 進程的plug_list的時候一般情況向該設備發下的request已經足夠多,很大可能性多到已經可以忽略性能提升的地步了。而且在這裡直接進行設備的泄流,關鍵在於邏輯足夠簡單,無其他調用分支,代碼可讀性更強。

另外,如果上層發下來的bio無法合併,那麼必然可能會產生很多的request,且這些request都足夠小,對於這些足夠小且不連續的IO,我們即使聚集再多進行一次性泄流,也沒什麼大用,無法實質性的提升IO性能。

綜合上述幾種原因,所以我覺得這就是為什麼新版內核中去掉了老版本內核那種複雜的臨界值控制,而是將控制下放給了每個進程自己。這樣解放了request_queue的實現,使得代碼可維護性更強。

接下來我們就來欣賞更為酸爽的泄流邏輯:

// 參數from_schedule決定是走同步泄流還是非同步泄流static void queue_unplugged(struct request_queue *q, unsigned int depth, bool from_schedule) __releases(q->queue_lock){ trace_block_unplug(q, depth, !from_schedule); if (from_schedule) // 非同步泄流 blk_run_queue_async(q); else // 同步泄流 __blk_run_queue(q); spin_unlock(q->queue_lock);}

同步泄流

同步泄流,即等著泄流過程完成,調用函數blk_run_queue

void __blk_run_queue(struct request_queue *q){ if (unlikely(blk_queue_stopped(q))) return; __blk_run_queue_uncond(q);}inline void __blk_run_queue_uncond(struct request_queue *q){ if (unlikely(blk_queue_dead(q))) return; q->request_fn_active++; q->request_fn(q); q->request_fn_active--;}

最終調用了q->request_fn(),也即scsi_request_fn():

static void scsi_request_fn(struct request_queue *q){ ...... for (;;) { req = blk_peek_request(q); if (!req || !scsi_dev_queue_ready(q, sdev)) break; if (!(blk_queue_tagged(q) && !blk_queue_start_tag(q, req))) blk_start_request(req); sdev->device_busy++; spin_unlock(q->queue_lock); ...... spin_lock(shost->host_lock); if (blk_queue_tagged(q) && !blk_rq_tagged(req)) { if (list_empty(&sdev->starved_entry)) list_add_tail(&sdev->starved_entry, &shost->starved_list); goto not_ready; } scsi_init_cmd_errh(cmd); rtn = scsi_dispatch_cmd(cmd); spin_lock_irq(q->queue_lock); if (rtn) goto out_delay; } goto out;not_ready: spin_unlock_irq(shost->host_lock); spin_lock_irq(q->queue_lock); blk_requeue_request(q, req); sdev->device_busy--;out_delay: if (sdev->device_busy == 0) blk_delay_queue(q, SCSI_QUEUE_DELAY);out: spin_unlock_irq(q->queue_lock); put_device(&sdev->sdev_gendev); spin_lock_irq(q->queue_lock);}

對比一下發現,新版內核的泄流邏輯其實和老版本差不多,我們就不再仔細描述,只說他們之間的區別:

* 無需判斷是否需要在合適的時機進行泄流了,這是因為泄流目前已經變成每個進程自己的事情;所以request_queue不再管泄流的事情;

* 無法再泄流的時候(超過底層設備的處理能力了),調用blk_delay_queue,延遲泄流,現在下面接不住了,過會再泄。這裡可以簡單地把其理解為一個延時任務,在延時時間到達的時候該任務就會執行,開始下一次泄流。

相較於老版本複雜的邏輯,去掉了泄流時機的判斷,整個邏輯是不是變得非常清晰,至少我這麼覺得。

非同步泄流

看完了同步泄流,我們接下來大概看看非同步泄流,它應該比同步泄流更簡單,按照我的理解,其無非就是安排一個定時任務,到時間去做就好了。

void blk_run_queue_async(struct request_queue *q){ if (likely(!blk_queue_stopped(q) && !blk_queue_dead(q))) mod_delayed_work(kblockd_workqueue, &q->delay_work, 0);}

至於Linux內核的定時任務,那不是我們這裡研究的重點,有機會再分析吧。

總結

在這篇文章中,我們相當仔細地分析了塊設備層的優化-蓄流和泄流。我們從老版本的內核分析起,仔細闡述了蓄流泄流原因以及內部原理。接下來,我們思考該版本實現的問題:

  • 性能
  • 代碼可讀性

根據我們自己的思考對老版本內核的實現提出改進意見,結合新版本內核的實現分析,確實符合我們提出的癥結,且在新版本的實現中通過巧妙的方式有效地解決了問題,實現了性能和可讀性的統一,不甚妙哉。

推薦閱讀:

關於Linux操作系統下C語言編程注意事項
Shell 輸入/輸出重定向
CentOS下tar包(源碼包)和rpm包(二進位包)的安裝
黑客三部曲之二黑客艷情

TAG:操作系統 | Linux | 文件系統 |