跳转至

35 块设备(下):如何建立代理商销售模式?

文件系统那一节,我们讲了文件的写入,到了设备驱动这一层,就没有再往下分析。上一节我们又讲了mount一个块设备,将block_device信息放到了ext4文件系统的super_block里面,有了这些基础,是时候把整个写入的故事串起来了。

还记得咱们在文件系统那一节分析写入流程的时候,对于ext4文件系统,最后调用的是ext4_file_write_iter,它将I/O的调用分成两种情况:

第一是直接I/O。最终我们调用的是generic_file_direct_write,这里调用的是mapping->a_ops->direct_IO,实际调用的是ext4_direct_IO,往设备层写入数据。

第二种是缓存I/O。最终我们会将数据从应用拷贝到内存缓存中,但是这个时候,并不执行真正的I/O操作。它们只将整个页或其中部分标记为脏。写操作由一个timer触发,那个时候,才调用wb_workfn往硬盘写入页面。

接下来的调用链为:wb_workfn->wb_do_writeback->wb_writeback->writeback_sb_inodes->__writeback_single_inode->do_writepages。在do_writepages中,我们要调用mapping->a_ops->writepages,但实际调用的是ext4_writepages,往设备层写入数据。

这一节,我们就沿着这两种情况分析下去。

直接I/O如何访问块设备?

我们先来看第一种情况,直接I/O调用到ext4_direct_IO。

static ssize_t ext4_direct_IO(struct kiocb *iocb, struct iov_iter *iter)
{
    struct file *file = iocb->ki_filp;
    struct inode *inode = file->f_mapping->host;
    size_t count = iov_iter_count(iter);
    loff_t offset = iocb->ki_pos;
    ssize_t ret;
......
    ret = ext4_direct_IO_write(iocb, iter);
......
}


static ssize_t ext4_direct_IO_write(struct kiocb *iocb, struct iov_iter *iter)
{
    struct file *file = iocb->ki_filp;
    struct inode *inode = file->f_mapping->host;
    struct ext4_inode_info *ei = EXT4_I(inode);
    ssize_t ret;
    loff_t offset = iocb->ki_pos;
    size_t count = iov_iter_count(iter);
......
    ret = __blockdev_direct_IO(iocb, inode, inode->i_sb->s_bdev, iter,
                   get_block_func, ext4_end_io_dio, NULL,
                   dio_flags);


……
}

在ext4_direct_IO_write调用__blockdev_direct_IO,有个参数你需要特别注意一下,那就是inode->i_sb->s_bdev。通过当前文件的inode,我们可以得到super_block。这个super_block中的s_bdev,就是咱们上一节填进去的那个block_device。

__blockdev_direct_IO会调用do_blockdev_direct_IO,在这里面我们要准备一个struct dio结构和struct dio_submit结构,用来描述将要发生的写入请求。

static inline ssize_t
do_blockdev_direct_IO(struct kiocb *iocb, struct inode *inode,
              struct block_device *bdev, struct iov_iter *iter,
              get_block_t get_block, dio_iodone_t end_io,
              dio_submit_t submit_io, int flags)
{
    unsigned i_blkbits = ACCESS_ONCE(inode->i_blkbits);
    unsigned blkbits = i_blkbits;
    unsigned blocksize_mask = (1 << blkbits) - 1;
    ssize_t retval = -EINVAL;
    size_t count = iov_iter_count(iter);
    loff_t offset = iocb->ki_pos;
    loff_t end = offset + count;
    struct dio *dio;
    struct dio_submit sdio = { 0, };
    struct buffer_head map_bh = { 0, };
......
    dio = kmem_cache_alloc(dio_cache, GFP_KERNEL);
    dio->flags = flags;
    dio->i_size = i_size_read(inode);
    dio->inode = inode;
    if (iov_iter_rw(iter) == WRITE) {
        dio->op = REQ_OP_WRITE;
        dio->op_flags = REQ_SYNC | REQ_IDLE;
        if (iocb->ki_flags & IOCB_NOWAIT)
            dio->op_flags |= REQ_NOWAIT;
    } else {
        dio->op = REQ_OP_READ;
    }
    sdio.blkbits = blkbits;
    sdio.blkfactor = i_blkbits - blkbits;
    sdio.block_in_file = offset >> blkbits;


    sdio.get_block = get_block;
    dio->end_io = end_io;
    sdio.submit_io = submit_io;
    sdio.final_block_in_bio = -1;
    sdio.next_block_for_io = -1;


    dio->iocb = iocb;
    dio->refcount = 1;


    sdio.iter = iter;
    sdio.final_block_in_request =
        (offset + iov_iter_count(iter)) >> blkbits;
......
    sdio.pages_in_io += iov_iter_npages(iter, INT_MAX);


    retval = do_direct_IO(dio, &sdio, &map_bh);
.....
}

do_direct_IO里面有两层循环,第一层循环是依次处理这次要写入的所有块。对于每一块,取出对应的内存中的页page,在这一块中,有写入的起始地址from和终止地址to,所以,第二层循环就是依次处理from到to的数据,调用submit_page_section,提交到块设备层进行写入。

static int do_direct_IO(struct dio *dio, struct dio_submit *sdio,
            struct buffer_head *map_bh)
{
    const unsigned blkbits = sdio->blkbits;
    const unsigned i_blkbits = blkbits + sdio->blkfactor;
    int ret = 0;


    while (sdio->block_in_file < sdio->final_block_in_request) {
        struct page *page;
        size_t from, to;


        page = dio_get_page(dio, sdio);
        from = sdio->head ? 0 : sdio->from;
        to = (sdio->head == sdio->tail - 1) ? sdio->to : PAGE_SIZE;
        sdio->head++;


        while (from < to) {
            unsigned this_chunk_bytes;  /* # of bytes mapped */
            unsigned this_chunk_blocks; /* # of blocks */
......
            ret = submit_page_section(dio, sdio, page,
                          from,
                          this_chunk_bytes,
                          sdio->next_block_for_io,
                          map_bh);
......
            sdio->next_block_for_io += this_chunk_blocks;
            sdio->block_in_file += this_chunk_blocks;
            from += this_chunk_bytes;
            dio->result += this_chunk_bytes;
            sdio->blocks_available -= this_chunk_blocks;
            if (sdio->block_in_file == sdio->final_block_in_request)
                break;
......
        }
    }
}

submit_page_section会调用dio_bio_submit,进而调用submit_bio向块设备层提交数据。其中,参数struct bio是将数据传给块设备的通用传输对象。定义如下:

/**
 * submit_bio - submit a bio to the block device layer for I/O
 * @bio: The &struct bio which describes the I/O
 */
blk_qc_t submit_bio(struct bio *bio)
{
......
  return generic_make_request(bio);
}

缓存I/O如何访问块设备?

我们再来看第二种情况,缓存I/O调用到ext4_writepages。这个函数比较长,我们这里只截取最重要的部分来讲解。

static int ext4_writepages(struct address_space *mapping,
               struct writeback_control *wbc)
{
......
    struct mpage_da_data mpd;
    struct inode *inode = mapping->host;
    struct ext4_sb_info *sbi = EXT4_SB(mapping->host->i_sb);
......
    mpd.do_map = 0;
    mpd.io_submit.io_end = ext4_init_io_end(inode, GFP_KERNEL);
    ret = mpage_prepare_extent_to_map(&mpd);
    /* Submit prepared bio */
    ext4_io_submit(&mpd.io_submit);
......
}

这里比较重要的一个数据结构是struct mpage_da_data。这里面有文件的inode、要写入的页的偏移量,还有一个重要的struct ext4_io_submit,里面有通用传输对象bio。

struct mpage_da_data {
    struct inode *inode;
......
    pgoff_t first_page; /* The first page to write */
    pgoff_t next_page;  /* Current page to examine */
    pgoff_t last_page;  /* Last page to examine */
    struct ext4_map_blocks map;
    struct ext4_io_submit io_submit;    /* IO submission data */
    unsigned int do_map:1;
};


struct ext4_io_submit {
......
    struct bio      *io_bio;
    ext4_io_end_t       *io_end;
    sector_t        io_next_block;
};

在ext4_writepages中,mpage_prepare_extent_to_map用于初始化这个struct mpage_da_data结构。接下来的调用链为:mpage_prepare_extent_to_map->mpage_process_page_bufs->mpage_submit_page->ext4_bio_write_page->io_submit_add_bh。

在io_submit_add_bh中,此时的bio还是空的,因而我们要调用io_submit_init_bio,初始化bio。

static int io_submit_init_bio(struct ext4_io_submit *io,
                  struct buffer_head *bh)
{
    struct bio *bio;


    bio = bio_alloc(GFP_NOIO, BIO_MAX_PAGES);
    if (!bio)
        return -ENOMEM;
    wbc_init_bio(io->io_wbc, bio);
    bio->bi_iter.bi_sector = bh->b_blocknr * (bh->b_size >> 9);
    bio->bi_bdev = bh->b_bdev;
    bio->bi_end_io = ext4_end_bio;
    bio->bi_private = ext4_get_io_end(io->io_end);
    io->io_bio = bio;
    io->io_next_block = bh->b_blocknr;
    return 0;
}

我们再回到ext4_writepages中。在bio初始化完之后,我们要调用ext4_io_submit,提交I/O。在这里我们又是调用submit_bio,向块设备层传输数据。ext4_io_submit的实现如下:

void ext4_io_submit(struct ext4_io_submit *io)
{
    struct bio *bio = io->io_bio;


    if (bio) {
        int io_op_flags = io->io_wbc->sync_mode == WB_SYNC_ALL ?
                  REQ_SYNC : 0;
        io->io_bio->bi_write_hint = io->io_end->inode->i_write_hint;
        bio_set_op_attrs(io->io_bio, REQ_OP_WRITE, io_op_flags);
        submit_bio(io->io_bio);
    }
    io->io_bio = NULL;
}

如何向块设备层提交请求?

既然不管是直接I/O,还是缓存I/O,最后都到了submit_bio里面,那我们就来重点分析一下它。

submit_bio会调用generic_make_request。代码如下:

blk_qc_t generic_make_request(struct bio *bio)
{
    /*
     * bio_list_on_stack[0] contains bios submitted by the current
     * make_request_fn.
     * bio_list_on_stack[1] contains bios that were submitted before
     * the current make_request_fn, but that haven't been processed
     * yet.
     */
    struct bio_list bio_list_on_stack[2];
    blk_qc_t ret = BLK_QC_T_NONE;
......
    if (current->bio_list) {
        bio_list_add(&current->bio_list[0], bio);
        goto out;
    }


    bio_list_init(&bio_list_on_stack[0]);
    current->bio_list = bio_list_on_stack;
    do {
        struct request_queue *q = bdev_get_queue(bio->bi_bdev);


        if (likely(blk_queue_enter(q, bio->bi_opf & REQ_NOWAIT) == 0)) {
            struct bio_list lower, same;


            /* Create a fresh bio_list for all subordinate requests */
            bio_list_on_stack[1] = bio_list_on_stack[0];
            bio_list_init(&bio_list_on_stack[0]);
            ret = q->make_request_fn(q, bio);


            blk_queue_exit(q);


            /* sort new bios into those for a lower level
             * and those for the same level
             */
            bio_list_init(&lower);
            bio_list_init(&same);
            while ((bio = bio_list_pop(&bio_list_on_stack[0])) != NULL)
                if (q == bdev_get_queue(bio->bi_bdev))
                    bio_list_add(&same, bio);
                else
                    bio_list_add(&lower, bio);
            /* now assemble so we handle the lowest level first */
            bio_list_merge(&bio_list_on_stack[0], &lower);
            bio_list_merge(&bio_list_on_stack[0], &same);
            bio_list_merge(&bio_list_on_stack[0], &bio_list_on_stack[1]);
        } 
......
        bio = bio_list_pop(&bio_list_on_stack[0]);
    } while (bio);
    current->bio_list = NULL; /* deactivate */
out:
    return ret;
}

这里的逻辑有点复杂,我们先来看大的逻辑。在do-while中,我们先是获取一个请求队列request_queue,然后调用这个队列的make_request_fn函数。

块设备队列结构

如果再来看struct block_device结构和struct gendisk结构,我们会发现,每个块设备都有一个请求队列struct request_queue,用于处理上层发来的请求。

在每个块设备的驱动程序初始化的时候,会生成一个request_queue。

struct request_queue {
    /*
     * Together with queue_head for cacheline sharing
     */
    struct list_head    queue_head;
    struct request      *last_merge;
    struct elevator_queue   *elevator;
......
    request_fn_proc     *request_fn;
    make_request_fn     *make_request_fn;
......
}

在请求队列request_queue上,首先是有一个链表list_head,保存请求request。

struct request {
    struct list_head queuelist;
......
    struct request_queue *q;
......
    struct bio *bio;
    struct bio *biotail;
......
}

每个request包括一个链表的struct bio,有指针指向一头一尾。

struct bio {
    struct bio      *bi_next;   /* request queue link */
    struct block_device *bi_bdev;
    blk_status_t        bi_status;
......
    struct bvec_iter    bi_iter;
    unsigned short      bi_vcnt;    /* how many bio_vec's */
    unsigned short      bi_max_vecs;    /* max bvl_vecs we can hold */
    atomic_t        __bi_cnt;   /* pin count */
    struct bio_vec      *bi_io_vec; /* the actual vec list */
......
};


struct bio_vec {
    struct page *bv_page;
    unsigned int    bv_len;
    unsigned int    bv_offset;
}

在bio中,bi_next是链表中的下一项,struct bio_vec指向一组页面。

在请求队列request_queue上,还有两个重要的函数,一个是make_request_fn函数,用于生成request;另一个是request_fn函数,用于处理request。

块设备的初始化

我们还是以scsi驱动为例。在初始化设备驱动的时候,我们会调用scsi_alloc_queue,把request_fn设置为scsi_request_fn。我们还会调用blk_init_allocated_queue->blk_queue_make_request,把make_request_fn设置为blk_queue_bio。

/**
 * scsi_alloc_sdev - allocate and setup a scsi_Device
 * @starget: which target to allocate a &scsi_device for
 * @lun: which lun
 * @hostdata: usually NULL and set by ->slave_alloc instead
 *
 * Description:
 *     Allocate, initialize for io, and return a pointer to a scsi_Device.
 *     Stores the @shost, @channel, @id, and @lun in the scsi_Device, and
 *     adds scsi_Device to the appropriate list.
 *
 * Return value:
 *     scsi_Device pointer, or NULL on failure.
 **/
static struct scsi_device *scsi_alloc_sdev(struct scsi_target *starget,
                       u64 lun, void *hostdata)
{
    struct scsi_device *sdev;
    sdev = kzalloc(sizeof(*sdev) + shost->transportt->device_size,
               GFP_ATOMIC);
......
    sdev->request_queue = scsi_alloc_queue(sdev);
......
}


struct request_queue *scsi_alloc_queue(struct scsi_device *sdev)
{
    struct Scsi_Host *shost = sdev->host;
    struct request_queue *q;


    q = blk_alloc_queue_node(GFP_KERNEL, NUMA_NO_NODE);
    if (!q)
        return NULL;
    q->cmd_size = sizeof(struct scsi_cmnd) + shost->hostt->cmd_size;
    q->rq_alloc_data = shost;
    q->request_fn = scsi_request_fn;
    q->init_rq_fn = scsi_init_rq;
    q->exit_rq_fn = scsi_exit_rq;
    q->initialize_rq_fn = scsi_initialize_rq;


    //调用blk_queue_make_request(q, blk_queue_bio);
    if (blk_init_allocated_queue(q) < 0) {
        blk_cleanup_queue(q);
        return NULL;
    }


    __scsi_init_queue(shost, q);
......
    return q
}

在blk_init_allocated_queue中,除了初始化make_request_fn函数,我们还要做一件很重要的事情,就是初始化I/O的电梯算法。

int blk_init_allocated_queue(struct request_queue *q)
{
    q->fq = blk_alloc_flush_queue(q, NUMA_NO_NODE, q->cmd_size);
......
    blk_queue_make_request(q, blk_queue_bio);
......
    /* init elevator */
    if (elevator_init(q, NULL)) {
......
    }
......
}

电梯算法有很多种类型,定义为elevator_type。下面我来逐一说一下。

  • struct elevator_type elevator_noop

Noop调度算法是最简单的IO调度算法,它将IO请求放入到一个FIFO队列中,然后逐个执行这些IO请求。

  • struct elevator_type iosched_deadline

Deadline算法要保证每个IO请求在一定的时间内一定要被服务到,以此来避免某个请求饥饿。为了完成这个目标,算法中引入了两类队列,一类队列用来对请求按起始扇区序号进行排序,通过红黑树来组织,我们称为sort_list,按照此队列传输性能会比较高;另一类队列对请求按它们的生成时间进行排序,由链表来组织,称为fifo_list,并且每一个请求都有一个期限值。

  • struct elevator_type iosched_cfq

又看到了熟悉的CFQ完全公平调度算法。所有的请求会在多个队列中排序。同一个进程的请求,总是在同一队列中处理。时间片会分配到每个队列,通过轮询算法,我们保证了I/O带宽,以公平的方式,在不同队列之间进行共享。

elevator_init中会根据名称来指定电梯算法,如果没有选择,那就默认使用iosched_cfq。

请求提交与调度

接下来,我们回到generic_make_request函数中。调用队列的make_request_fn函数,其实就是调用blk_queue_bio。

static blk_qc_t blk_queue_bio(struct request_queue *q, struct bio *bio)
{
    struct request *req, *free;
    unsigned int request_count = 0;
......
    switch (elv_merge(q, &req, bio)) {
    case ELEVATOR_BACK_MERGE:
        if (!bio_attempt_back_merge(q, req, bio))
            break;
        elv_bio_merged(q, req, bio);
        free = attempt_back_merge(q, req);
        if (free)
            __blk_put_request(q, free);
        else
            elv_merged_request(q, req, ELEVATOR_BACK_MERGE);
        goto out_unlock;
    case ELEVATOR_FRONT_MERGE:
        if (!bio_attempt_front_merge(q, req, bio))
            break;
        elv_bio_merged(q, req, bio);
        free = attempt_front_merge(q, req);
        if (free)
            __blk_put_request(q, free);
        else
            elv_merged_request(q, req, ELEVATOR_FRONT_MERGE);
        goto out_unlock;
    default:
        break;
    }


get_rq:
    req = get_request(q, bio->bi_opf, bio, GFP_NOIO);
......
    blk_init_request_from_bio(req, bio);
......
    add_acct_request(q, req, where);
    __blk_run_queue(q);
out_unlock:
......
    return BLK_QC_T_NONE;
}

blk_queue_bio首先做的一件事情是调用elv_merge来判断,当前这个bio请求是否能够和目前已有的request合并起来,成为同一批I/O操作,从而提高读取和写入的性能。

判断标准和struct bio的成员struct bvec_iter有关,它里面有两个变量,一个是起始磁盘簇bi_sector,另一个是大小bi_size。

enum elv_merge elv_merge(struct request_queue *q, struct request **req,
        struct bio *bio)
{
    struct elevator_queue *e = q->elevator;
    struct request *__rq;
......
    if (q->last_merge && elv_bio_merge_ok(q->last_merge, bio)) {
        enum elv_merge ret = blk_try_merge(q->last_merge, bio);


        if (ret != ELEVATOR_NO_MERGE) {
            *req = q->last_merge;
            return ret;
        }
    }
......
    __rq = elv_rqhash_find(q, bio->bi_iter.bi_sector);
    if (__rq && elv_bio_merge_ok(__rq, bio)) {
        *req = __rq;
        return ELEVATOR_BACK_MERGE;
    }


    if (e->uses_mq && e->type->ops.mq.request_merge)
        return e->type->ops.mq.request_merge(q, req, bio);
    else if (!e->uses_mq && e->type->ops.sq.elevator_merge_fn)
        return e->type->ops.sq.elevator_merge_fn(q, req, bio);


    return ELEVATOR_NO_MERGE;
}

elv_merge尝试了三次合并。

第一次,它先判断和上一次合并的request能不能再次合并,看看能不能赶上马上要走的这部电梯。在blk_try_merge主要做了这样的判断:如果blk_rq_pos(rq) + blk_rq_sectors(rq) == bio->bi_iter.bi_sector,也就是说这个request的起始地址加上它的大小(其实是这个request的结束地址),如果和bio的起始地址能接得上,那就把bio放在request的最后,我们称为ELEVATOR_BACK_MERGE。

如果blk_rq_pos(rq) - bio_sectors(bio) == bio->bi_iter.bi_sector,也就是说,这个request的起始地址减去bio的大小等于bio的起始地址,这说明bio放在request的最前面能够接得上,那就把bio放在request的最前面,我们称为ELEVATOR_FRONT_MERGE。否则,那就不合并,我们称为ELEVATOR_NO_MERGE。

enum elv_merge blk_try_merge(struct request *rq, struct bio *bio)
{
......
    if (blk_rq_pos(rq) + blk_rq_sectors(rq) == bio->bi_iter.bi_sector)
        return ELEVATOR_BACK_MERGE;
    else if (blk_rq_pos(rq) - bio_sectors(bio) == bio->bi_iter.bi_sector)
        return ELEVATOR_FRONT_MERGE;
    return ELEVATOR_NO_MERGE;
}

第二次,如果和上一个合并过的request无法合并,那我们就调用elv_rqhash_find。然后按照bio的起始地址查找request,看有没有能够合并的。如果有的话,因为是按照起始地址找的,应该接在人家的后面,所以是ELEVATOR_BACK_MERGE。

第三次,调用elevator_merge_fn试图合并。对于iosched_cfq,调用的是cfq_merge。在这里面,cfq_find_rq_fmerge会调用elv_rb_find函数,里面的参数是bio的结束地址。我们还是要看,能不能找到可以合并的。如果有的话,因为是按照结束地址找的,应该接在人家前面,所以是ELEVATOR_FRONT_MERGE。

static enum elv_merge cfq_merge(struct request_queue *q, struct request **req,
             struct bio *bio)
{
    struct cfq_data *cfqd = q->elevator->elevator_data;
    struct request *__rq;


    __rq = cfq_find_rq_fmerge(cfqd, bio);
    if (__rq && elv_bio_merge_ok(__rq, bio)) {
        *req = __rq;
        return ELEVATOR_FRONT_MERGE;
    }


    return ELEVATOR_NO_MERGE;
}


static struct request *
cfq_find_rq_fmerge(struct cfq_data *cfqd, struct bio *bio)
{
    struct task_struct *tsk = current;
    struct cfq_io_cq *cic;
    struct cfq_queue *cfqq;


    cic = cfq_cic_lookup(cfqd, tsk->io_context);
    if (!cic)
        return NULL;


    cfqq = cic_to_cfqq(cic, op_is_sync(bio->bi_opf));
    if (cfqq)
        return elv_rb_find(&cfqq->sort_list, bio_end_sector(bio));


    return NUL
}

等从elv_merge返回blk_queue_bio的时候,我们就知道,应该做哪种类型的合并,接着就要进行真的合并。如果没有办法合并,那就调用get_request,创建一个新的request,调用blk_init_request_from_bio,将bio放到新的request里面,然后调用add_acct_request,把新的request加到request_queue队列中。

至此,我们解析完了generic_make_request中最重要的两大逻辑:获取一个请求队列request_queue和调用这个队列的make_request_fn函数。

其实,generic_make_request其他部分也很令人困惑。感觉里面有特别多的struct bio_list,倒腾过来,倒腾过去的。这是因为,很多块设备是有层次的。

比如,我们用两块硬盘组成RAID,两个RAID盘组成LVM,然后我们就可以在LVM上创建一个块设备给用户用,我们称接近用户的块设备为高层次的块设备,接近底层的块设备为低层次(lower)的块设备。这样,generic_make_request把I/O请求发送给高层次的块设备的时候,会调用高层块设备的make_request_fn,高层块设备又要调用generic_make_request,将请求发送给低层次的块设备。虽然块设备的层次不会太多,但是对于代码generic_make_request来讲,这可是递归的调用,一不小心,就会递归过深,无法正常退出,而且内核栈的大小又非常有限,所以要比较小心。

这里你是否理解了struct bio_list bio_list_on_stack[2]的名字为什么叫stack呢?其实,将栈的操作变成对于队列的操作,队列不在栈里面,会大很多。每次generic_make_request被当前任务调用的时候,将current->bio_list设置为bio_list_on_stack,并在generic_make_request的一开始就判断current->bio_list是否为空。如果不为空,说明已经在generic_make_request的调用里面了,就不必调用make_request_fn进行递归了,直接把请求加入到bio_list里面就可以了,这就实现了递归的及时退出。

如果current->bio_list为空,那我们就将current->bio_list设置为bio_list_on_stack后,进入do-while循环,做咱们分析过的generic_make_request的两大逻辑。但是,当前的队列调用make_request_fn的时候,在make_request_fn的具体实现中,会生成新的bio。调用更底层的块设备,也会生成新的bio,都会放在bio_list_on_stack的队列中,是一个边处理还边创建的过程。

bio_list_on_stack[1] = bio_list_on_stack[0]这一句在make_request_fn之前,将之前队列里面遗留没有处理的保存下来,接着bio_list_init将bio_list_on_stack[0]设置为空,然后调用make_request_fn,在make_request_fn里面如果有新的bio生成,都会加到bio_list_on_stack[0]这个队列里面来。

make_request_fn执行完毕后,可以想象bio_list_on_stack[0]可能又多了一些bio了,接下来的循环中调用bio_list_pop将bio_list_on_stack[0]积攒的bio拿出来,分别放在两个队列lower和same中,顾名思义,lower就是更低层次的块设备的bio,same是同层次的块设备的bio。

接下来我们能将lower、same以及bio_list_on_stack[1] 都取出来,放在bio_list_on_stack[0]统一进行处理。当然应该lower优先了,因为只有底层的块设备的I/O做完了,上层的块设备的I/O才能做完。

到这里,generic_make_request的逻辑才算解析完毕。对于写入的数据来讲,其实仅仅是将bio请求放在请求队列上,设备驱动程序还没往设备里面写呢。

请求的处理

设备驱动程序往设备里面写,调用的是请求队列request_queue的另外一个函数request_fn。对于scsi设备来讲,调用的是scsi_request_fn。

static void scsi_request_fn(struct request_queue *q)
    __releases(q->queue_lock)
    __acquires(q->queue_lock)
{
    struct scsi_device *sdev = q->queuedata;
    struct Scsi_Host *shost;
    struct scsi_cmnd *cmd;
    struct request *req;


    /*
     * To start with, we keep looping until the queue is empty, or until
     * the host is no longer able to accept any more requests.
     */
    shost = sdev->host;
    for (;;) {
        int rtn;
        /*
         * get next queueable request.  We do this early to make sure
         * that the request is fully prepared even if we cannot
         * accept it.
         */
        req = blk_peek_request(q);
......
        /*
         * Remove the request from the request list.
         */
        if (!(blk_queue_tagged(q) && !blk_queue_start_tag(q, req)))
            blk_start_request(req);
.....
        cmd = req->special;
......
        /*
         * Dispatch the command to the low-level driver.
         */
        cmd->scsi_done = scsi_done;
        rtn = scsi_dispatch_cmd(cmd);
......
    }
    return;
......
}

在这里面是一个for无限循环,从request_queue中读取request,然后封装更加底层的指令,给设备控制器下指令,实施真正的I/O操作。

总结时刻

这一节我们讲了如何将块设备I/O请求送达到外部设备。

对于块设备的I/O操作分为两种,一种是直接I/O,另一种是缓存I/O。无论是哪种I/O,最终都会调用submit_bio提交块设备I/O请求。

对于每一种块设备,都有一个gendisk表示这个设备,它有一个请求队列,这个队列是一系列的request对象。每个request对象里面包含多个BIO对象,指向page cache。所谓的写入块设备,I/O就是将page cache里面的数据写入硬盘。

对于请求队列来讲,还有两个函数,一个函数叫make_request_fn函数,用于将请求放入队列。submit_bio会调用generic_make_request,然后调用这个函数。

另一个函数往往在设备驱动程序里实现,我们叫request_fn函数,它用于从队列里面取出请求来,写入外部设备。

至此,整个写入文件的过程才算完全结束。这真是个复杂的过程,涉及系统调用、内存管理、文件系统和输入输出。这足以说明,操作系统真的是一个非常复杂的体系,环环相扣,需要分层次层层展开来学习。

到这里,专栏已经过半了,你应该能发现,很多我之前说“后面会细讲”的东西,现在正在一点一点解释清楚,而文中越来越多出现“前面我们讲过”的字眼,你是否当时学习前面知识的时候,没有在意,导致学习后面的知识产生困惑了呢?没关系,及时倒回去复习,再回过头去看,当初学过的很多知识会变得清晰很多。

课堂练习

你知道如何查看磁盘调度算法、修改磁盘调度算法以及I/O队列的长度吗?

欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

精选留言(15)
  • geraltlaush 👍(23) 💬(1)

    文件系统中的page_cache对应逻辑上的块的概念,将page_cache打包成bio递交给通用块设备层,通用块设备层将多个bio打包成一个请求request, 尽量把bio对应的sector临近的数据合并,提交给块设备调度层,块设备调度层就是把各个request当成段提交给设备驱动程序,因为设备驱动程序只识别段,也就是说通用块设备层捣腾的其实就是把bio对应的页框和在磁盘中对应的sector进行合并的过程,调度层只负责把合并的request放进队列,用调度算法下发给驱动程序处理,其实所有复杂的合并操作以及内存和磁盘扇区的对应关系都是通过块设备层做了,老师,可以这么理解吧

    2019-06-22

  • djfhchdh 👍(6) 💬(1)

    /sys/block/xvda/queue/scheduler 磁盘的调度算法,临时修改:echo noop > /sys/block/xvda/queue/scheduler,永久修改就需要修改内核参数,然后重启。 iostat -d -x 中的avgqu-sz是平均I/O队列长度。

    2019-06-24

  • geraltlaush 👍(6) 💬(1)

    那个队列,队头和队尾的合并都是为了所谓的顺序写,减少机械硬盘寻址消耗吧,能赶上就上同一辆车,赶不上自己叫一辆车等满了再走,但是也是有超时等待时间的,可以这么理解吧

    2019-06-17

  • 眭东亮 👍(2) 💬(1)

    查看磁盘调度算法 cat /sys/block/sda/queue/scheduler

    2019-07-10

  • 安排 👍(2) 💬(1)

    往设备里面写的时候调用的是request_fn,这个函数是谁来调用的呢?触发这个调用的时机是什么呢?还是说请求一旦进入队列就会立即写入?

    2019-06-17

  • 蚂蚁内推+v 👍(1) 💬(1)

    会不会断电丢失数据呢?

    2019-06-19

  • 安排 👍(1) 💬(1)

    直接读写裸设备不会走文件系统,那还会走通用块层吗?

    2019-06-18

  • 游弋云端 👍(0) 💬(1)

    老师,是否可以认为直接 IO执行完成后,数据就是下盘的,还是不一定,还有驱动层以及磁盘缓存等因素。

    2019-09-01

  • 安排 👍(0) 💬(1)

    老师,希望在答疑篇能讲一讲request_fn取出请求之后的具体执行过程,具体的执行是不是和block_device有关,磁盘的最底层的操作是不是都在block_device中?

    2019-06-19

  • 石维康 👍(0) 💬(1)

    bio_list_merge(&bio_list_on_stack[0], &lower); bio_list_merge(&bio_list_on_stack[0], &same); bio_list_merge(&bio_list_on_stack[0], &bio_list_on_stack[1]); 请问这些加到bio_list_on_stack[0]上的bio是在什么时候被处理的?

    2019-06-17

  • hello 👍(9) 💬(0)

    太爽了,听了三四遍这节课,以前一直担心io在内核内部会发生多次拷贝,听完发现并没有。下一次我一定要边读源码边听。

    2019-09-26

  • 安排 👍(5) 💬(0)

    make_request_fn被初始化成了blk_queue_bio函数。submit_bio会调用到generic_make_request,然后进一步调用到make_request_fn,也就是blk_queue_bio。在blk_queue_bio里面其实是先尝试将bio合并到当前进程的plug_list里面的request,如果可以合并,则合并后直接返回了,如果不能合并,则接着向磁盘设备的request_queue中合并,向request_queue中合并的时候一定会成功(因为即使不能合并也会新生成一个request)。而每个进程的plug_list里面的request也会在适当的时候就行泄流,泄流的时候会调用到磁盘设备的request_queue里面的电梯成员的elevator_add_req_fn,这个函数就是讲plug_list里面的request加入到request_queue里面的电梯队列里,进行更进一步的合并。 设备驱动会调用request_queue的request_fn,平时写块设备驱动也就是申请一个request_queue,然后调用一些内核api初始化这个request_queue,并且实现自己的request_fn函数,request_fn会调用blk_peek_request从request_queue的queue_head成员取出request,并转化为更底层的指令来执行,如果发现queue_head为空,则会调用request_queue的elevator_dispatch_fn分发request。

    2019-06-18

  • 安排 👍(5) 💬(0)

    看了一点儿其它的资料,大概了解了一下,有以下几种情况:1、request_fn是在unplug泄流的时候调用(也就是队列里面的请求达到一定的数量) 2、或者是由定时器触发,也就是即使队列里面的请求很少,但是也不能无限期的不执行它们,所以在定时器超时后会调用request_fn 3、或者在添加request进行合并的时候,判断一下是哪种合并方式,如果是后向合并就会立即调用requset_fn。 不知道这样理解的对不对?

    2019-06-17

  • TableBear 👍(1) 💬(0)

    好晕啊

    2021-12-02

  • Yakmoz 👍(1) 💬(0)

    这里的page_cache和文件系统里的page_cache是不是不一样的? 不太明白,为什么直接IO 提交的时候也会操作page_cache

    2020-09-22