當 DirectIO 遇到 Loop 設備

前言

本文記錄了在使用 DirectIO 對 Loop 設備與真實設備進行吞吐量對比測試時,遇到的異常情況。通過該異常情況,分析了 Loop 設備的工作原理。

Loop 設備

/dev/loop 設備在 Linux 中是一種僞設備,這種設備可以讓文件如同塊設備一般被訪問,讓普通文件可以像塊設備被格式化文件系統,可以進行掛載。Loop 設備必須與一個現有的文件進行關聯,如果文件中包含文件系統,那麼這個文件就可以被掛載。例如,查看當前系統空閒的 Loop 設備:

losetup -f

關聯空閒設備 loop1 到一個現有文件 test.img,格式化爲 ext4 文件系統並掛載到./mnt 目錄下。

losetup /dev/loop1 test.img

mount /dev/loop1 ./mnt/

mkfs.ext4 /dev/loop1

DirectIO

直接 IO 是一種無緩衝的 IO,對文件的讀寫操作不會經過操作系統內核中的文件緩存。內核文件緩存中提供的預讀取、延遲寫入等機制不一定適合所有應用場景,例如數據庫通常有一套自身的緩存機制和落盤機制,如果沒有能夠繞過內核文件緩存的機制,就會存在雙重緩存。DirectIO 就提供了繞過內核文件緩存的方法。

在 open 文件的時候設置 O_DIRECT 標識就可以使用 DirectIO。

fd = open(argv[1], O_RDONLY | O_DIRECT);

DirectIO 吞吐量對比

分別對真實設備和 Loop 設備進行吞吐量測試。實驗猜想:真實設備的吞吐量會明顯高於 Loop 設備。因爲採用 DirectIO 都繞過內核緩存去設備中讀取數據的情況下,Loop 設備因爲是關聯的一個已存在的文件,會轉化爲對原鏡像文件 test.img 的讀取,在經過兩層 IO 棧的情況下,吞吐量會低於直接讀取真實設備。

吞吐量測試程序如下:

        fd = open(argv[1], O_RDONLY | O_DIRECT);
        if (fd == -1)
                printf("open\n");

        gettimeofday(&start, NULL);
        while (numRead != 0) {
                numRead = read(fd, buf, size);
                totalnumRead += numRead;

                if (numRead == -1) {
                        printf("Error\n");
                        return 0;
                }

        }
        gettimeofday(&end, NULL);
        double elapsed_time = ((double)t)/CLOCKS_PER_SEC;
        secs_used=(end.tv_sec - start.tv_sec);
        micros_used = secs_used*1000000 + end.tv_usec - start.tv_usec;
        printf("Throughput = %f\n"(double)totalnumRead/micros_used);

使用該程序分別讀取 Loop 設備中的文件./mnt/loop_file,真實設備中的文件./device_file。

真實設備吞吐量如下圖所示,在 120 左右:

Loop 設備吞吐量如下圖所示,在 700 左右:

實驗結果與猜想正好相反,Loop 設備的吞吐量遠大於真實設備。是什麼原因造成了這種結果呢?

實驗現象分析

Loop 設備的吞吐量如此高,猜想它在設置 O_DIRECT 標識的情況下,還是使用了內核中的文件緩存,纔會比真實設備快。通過如下兩步進行驗證:

  1. 查看測試程序執行前後,系統中 cache 大小的變化情況。

  2. 通過 trace 工具根據內核中函數調用棧,判斷測試程序是走的 buff IO 分支還是 direct IO 分支。

cache 大小分析

首先清空系統中的文件 cache,清空後的 buff/cache 大小爲 213816KB。

#!/bin/bash
free
sync; sudo echo 3 > /proc/sys/vm/drop_caches
free

執行 Loop 設備下的吞吐量測試程序,可以看到清空緩存後,第一次執行,吞吐量變低了,後續執行結果,又回到了高吞吐量。說明後續讀取內容都命中了緩存,吞吐量變高。

繼續查看現在系統中的 cache 大小,變成了 267688KB,增加了 50M 的大小。而我們讀寫的文件大小爲 40M,也很接近。

所以,在設置 O_DIRECT 標識的情況下對 Loop 設備中的文件進行讀取,Loop 設備還是使用了內核中的文件緩存。那麼 Loop 設備具體是在哪裏使用了緩存呢?

Loop 設備讀操作內核棧跟蹤

猜想 Loop 設備的調用棧如下圖所示,在這個調用棧裏面如果 Loop 設備使用緩存,最可能就是在下圖中兩個橙色的文件緩存處。接下來我們對這個假設進行驗證。

我們需要捕獲到讀取 loop 設備中文件的內核調用棧,才能驗證上圖是正確的。上圖中經過了兩次 ext4 文件系統,所以我們先跟蹤一下 ext4_file_read_iter 函數,看一次 read 是否調用了兩次該函數。

藉助 bcc,成功捕獲到了兩次 ext4 讀取操作。如下圖所示,第一次由 read 發起,第二次由 loop1 發起,而且第二次執行是由內核線程 kthread 執行的 kthread work,很明顯 loop_queue_work 是 loop 驅動設置的 work 回調函數。

loop_queue_work 由 loop 設備驅動初始化時設置的多請求隊列函數操作集中的 loop_init_request 函數設置,過程如下圖所示。當塊層的請求隊列進行請求派發時,就會喚醒 worker,執行該函數。(/drivers/block/loop.c)

下面我們需要跟蹤執行 loop_queue_work 的函數調用棧,查看第一段讀操作的 IO 棧完整路徑。這次我們掛載 loop 驅動中負責處理塊層多請求隊列派發的 request 函數,即上圖中的 loop_queue_rq,調用棧如下圖所示(自下向上)。

可以看到第一段 IO 棧由於設置了 O_DIRECT 標識的原因,確實沒有使用內核文件緩存,執行了 ext4_direct_IO 分支。

塊層一些函數的主要功能如下:

blk_finish_plug : IO請求泄流(進程)
blk_flush_plug_list : 發起泄流
blk_mq_flush_plug_list : 多請求隊列泄流
blk_mq_sched_insert_requests : 如果定義了調度算法則插入調度器。
blk_mq_run_hw_queue : 啓動硬件隊列,派發request到塊設備驅動
__blk_mq_delay_run_hw_queue :派發hctx->dispatch鏈表
blk_mq_sched_dispatch_requests : 執行各種派發(直接派發、軟件隊列派發、調度隊列派發)
blk_mq_do_dispatch_sched : 派發調度器的請求隊列
loop_queue_rq :即queue_rq,是一個鉤子函數,由具體的設備驅動定義用來處理request,loop_queue_rq是loop設備定義的處理函數,在驅動初始化時注入。

loop_queue_rq 最終會將 work 插入到 worker 隊列中,並喚醒睡眠的 worker->task,此刻 worker 上 work 的 work->func 得以執行,此處就是執行 loop_queue_work。

loop 驅動如何處理請求

從下圖 loop1 發起的第二次 IO 調用棧可以看到,loop 驅動又將請求轉發到了虛擬文件系統層,即函數 vfs_iter_read。此時就是對 loop 設備所關聯的鏡像文件進行 IO 操作了。接下來我們驗證一下,第二段 IO 操作是否使用了文件緩存。

我們挑選緩存讀中的負責預讀窗口初始化的函數 ondemand_readahead 進行跟蹤,結果如下圖所示。結果顯示了 generic_file_buffered_read 函數,第二段對鏡像文件 test.img 的讀取採用了 buff IO。

至此,已經能夠解釋爲什麼在設置了 O_DIRECT 標識的情況下,loop 設備的吞吐量比真實設備高那麼多。loop 設備在驅動層又將請求轉發到了 vfs 層,進行對其關聯鏡像文件的第二段讀取,在第二段讀取的時候依然採用了 buff IO。

讀取 loop 設備的整體 IO 棧如下圖所示。

後續工作

接下來分析一下,爲什麼第二段 IO 不能延續第一段 IO 的 O_DIRECT 標識。從 loop_queue_work 函數開始看 loop 驅動如何將第一次的請求轉化爲第二次的請求。

loop_queue_work
    loop_handle_cmd
        do_req_filebacked

do_req_filebacked 函數中對讀寫和不同的讀寫類型進行了處理,direct IO 是通過非阻塞 I/O 來進行 io 的轉發。可以看出,loop 設備關聯的鏡像文件是單獨進行讀寫方式設置的,與第一段 IO 讀寫的方式是不相干的。那麼 cmd->use_aio 是在何時進行設置的?

static int do_req_filebacked(struct loop_device *lo, struct request *rq)
{
 struct loop_cmd *cmd = blk_mq_rq_to_pdu(rq);
 loff_t pos = ((loff_t) blk_rq_pos(rq) << 9) + lo->lo_offset;
 switch (req_op(rq)) {//跟據request的flag對文件進行不同的操作
 case REQ_OP_FLUSH://flush操作
  return lo_req_flush(lo, rq);
 case REQ_OP_WRITE_ZEROES://discard操作
  return lo_fallocate(lo, rq, pos,
   (rq->cmd_flags & REQ_NOUNMAP) ?
    FALLOC_FL_ZERO_RANGE :
    FALLOC_FL_PUNCH_HOLE);
 case REQ_OP_DISCARD://discard操作
  return lo_fallocate(lo, rq, pos, FALLOC_FL_PUNCH_HOLE);
 case REQ_OP_WRITE://寫操作
  if (lo->transfer)//配置了加密算法
   return lo_write_transfer(lo, rq, pos);
  else if (cmd->use_aio)//關聯的鏡像文件設置了direct I/O
   return lo_rw_aio(lo, cmd, pos, WRITE);
  else//buff IO
   return lo_write_simple(lo, rq, pos);
 case REQ_OP_READ://讀操作
  if (lo->transfer)
   return lo_read_transfer(lo, rq, pos);
  else if (cmd->use_aio)//關聯的鏡像文件設置了direct I/O
   return lo_rw_aio(lo, cmd, pos, READ);
  else//buff IO
   return lo_read_simple(lo, rq, pos);
 default:
  WARN_ON_ONCE(1);
  return -EIO;
  break;
 }
}

從 man 手冊中 loop 下面可以看到,可以通過 ioctl 來設置 backing file 爲 direct IO 模式。

查看 loop 驅動中的 ioctl 處理函數,loop_set_dio 函數對該 ioctl 命令進行處理。

//drivers/block/loop.c
static int lo_ioctl(struct block_device *bdev, fmode_t mode,
 unsigned int cmd, unsigned long arg)
{
 struct loop_device *lo = bdev->bd_disk->private_data;
 int err;

 mutex_lock_nested(&lo->lo_ctl_mutex, 1);
 switch (cmd) {
 case LOOP_SET_FD:
  err = loop_set_fd(lo, mode, bdev, arg);
  break;
        ......
 case LOOP_SET_DIRECT_IO://處理direct IO設置
  err = -EPERM;
  if ((mode & FMODE_WRITE) || capable(CAP_SYS_ADMIN))
   err = loop_set_dio(lo, arg);
  break;
 .......
 }
}

__loop_update_dio 會對 lo->use_dio 進行判斷和配置。

static int loop_set_dio(struct loop_device *lo, unsigned long arg)
{
 int error = -ENXIO;
 if (lo->lo_state != Lo_bound)
  goto out;

 __loop_update_dio(lo, !!arg);
 if (lo->use_dio == !!arg)
  return 0;
 error = -EINVAL;
 out:
 return error;
}

但是我們在驅動處理請求時判斷 diretIO 是使用的 cmd->use_aio,這個變量是何時和 lo->use_dio 關聯起來的呢?

static blk_status_t loop_queue_rq(struct blk_mq_hw_ctx *hctx,
  const struct blk_mq_queue_data *bd)
{
 switch (req_op(cmd->rq)) {
 case REQ_OP_FLUSH:
 case REQ_OP_DISCARD:
 case REQ_OP_WRITE_ZEROES:
  cmd->use_aio = false;
  break;
 default://設置backing file的讀寫方式
  cmd->use_aio = lo->use_dio;
  break;
 }
 kthread_queue_work(&lo->worker, &cmd->work);
 return BLK_STS_OK;
}

loop_queue_rq 中對 cmd->use_aio 進行了賦值。最後,lo->use_dio 的默認值在何時設置的?loop_set_fd 函數負責將 loop 設備和某個文件進行關聯,在其中初始化了 lo->use_dio 爲 false。

static int loop_set_fd(struct loop_device *lo, fmode_t mode,
         struct block_device *bdev, unsigned int arg)
{
 struct file *file;
 struct inode *inode;
 struct address_space *mapping;
 int  lo_flags = 0;
 int  error;
 loff_t  size;
    ......
 lo->use_dio = false;//默認不採用directIO
 lo->lo_device = bdev;
 lo->lo_flags = lo_flags;
 lo->lo_backing_file = file;
 lo->transfer = NULL;
 lo->ioctl = NULL;
 lo->lo_sizelimit = 0;
    ......
 return error;
}

總結

至此,我們已經弄清了爲什麼 loop 設備在 direct IO 讀寫設置下,還是能夠使用內核文件緩存。總結如下:

  1. loop 設備關聯的 backing file ,其讀寫方式需要單獨進行設置。

  2. loop 設備關聯文件的讀寫方式默認爲 buff IO。

  3. 系統通過 ioctl 系統調用,設置 backing file 的讀寫方式,對應的 cmd 爲 LOOP_SET_DIRECT_IO。

期待與你共同成長

知書碼跡 融實用性和趣味性於一體,專注於 Linux 內核、JAVA、數據結構算法面試題、物聯網絡等領域熱點分享。以新視角分享碼農圈內技術乾貨。期待與你共同成長。個人主頁:https://szp2016.github.io/

微信號:知書碼跡

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/1f5k2P5HdcKy5EqXxGGhHw