閱讀329 返回首頁    go 阿裏雲 go 技術社區[雲棲]


關於文件寫入的原子性討論

​   文件的寫入是否是原子的?多個線程寫入同一個文件是否會寫錯亂?多個進程寫入同一個文件是否會寫錯亂?想必這些問題多多少少會對我們產生一定的困擾,即使知道結果,很多時候也很難將這其中的原理清晰的表達給提問者,侯捷曾說過,**源碼麵前,了無秘密**,那麼本文也希望從源代碼的角度分析上述問題。在開始之前我們需要補充一下Linux 文件相關的一些基礎原理,便於更好的看懂Linux源代碼。

​   學過Linux的讀者想必都應該知道文件的數據分為兩個部分,一個部分就是文件數據本身,另外一個部分則是文件的元數據,也就是inode、權限、擴展屬性、mtime、ctime、atime等等,inode對於一個文件來說及其的重要,可以唯一的標識一個文件(實際應該是inode + dev號,唯一標識一個文件,更準確來說應該是在同一個文件係統的前提下才成立,不同的文件係統inode是會重複的,不過這不是重點,姑且這裏不嚴謹的認為inode就是用來唯一標識一個文件的吧),內核中將inode號和文件的元數據構建為一個struct inode對象,該對象結構如下:

struct inode {
    umode_t         i_mode;
    uid_t           i_uid;
    gid_t           i_gid;
    unsigned long       i_ino;
    atomic_t        i_count;
    dev_t           i_rdev;
    loff_t          i_size;
    struct timespec     i_atime;
    struct timespec     i_mtime;
    struct timespec     i_ctime;
    .......// 省略
};

​   通過這個inode對象就可以關聯一個文件,然後對這個文件進行讀寫操作,Linux內核對於文件同樣也有一個struct file對象來表示,該對象結構如下:

struct file {
    .....
    const struct file_operations    *f_op;
    loff_t          f_pos;
    struct address_space    *f_mapping;
    ....// 省略
};

​   有幾個成員比較關鍵,一個是f_op,文件操作的方法集合,文件操作不用關心其底層的文件係統是什麼,直接通過f_op成員找到對應的方法即可。另外一個則是f_pos,也就是這個文件讀到哪裏了,或者說是寫到哪裏了,是一個偏移量。一個進程打開一個文件的時候就會在內核中創建一個struct file對象,讀取文件的時候則分為以下幾步:

  1. 通過fd找到對應對應的struct file對象
  2. 通過struct file對象獲取當前的offset,也就是讀取f_pos成員
  3. 通過f_op找到對應的操作方法,並傳入要讀取的偏移量進行數據的讀取
  4. 讀取完成後,重新設置新的offset

一次讀文件的過程便是如此,對應到代碼也是非常的清晰,如下:

// vfs_read -> do_sync_read
ssize_t do_sync_read(struct file *filp, char __user *buf, size_t len, loff_t *ppos)
{
    struct iovec iov = { .iov_base = buf, .iov_len = len };
    struct kiocb kiocb;
    ssize_t ret;
    // 設置要讀取的長度和開始的偏移量
    init_sync_kiocb(&kiocb, filp);
    kiocb.ki_pos = *ppos;
    kiocb.ki_left = len;
    kiocb.ki_nbytes = len;

    for (;;) {
        // 實際開始進行讀取操作
        ret = filp->f_op->aio_read(&kiocb, &iov, 1, kiocb.ki_pos);
        if (ret != -EIOCBRETRY)
            break;
        wait_on_retry_sync_kiocb(&kiocb);
    }

    if (-EIOCBQUEUED == ret)
        ret = wait_on_sync_kiocb(&kiocb);
    // 讀完後更新最後的offset
    *ppos = kiocb.ki_pos;
    return ret;
}

​   文件的寫入也是如此,拿到offet,調用實際的寫入方法,最後更新offset。到此為止一個文件的讀和寫的大體過程我們是清楚了,很顯然上述的過程並不是原子的,無論是文件的讀還是寫,都至少有兩個步驟,一個是拿offset,另外一個則是實際的讀和寫。並且在整個過程中並沒有看到加鎖的動作,那麼第一個問題就得到了解決。對於第二個問題我們可以簡要的分析下,假如有兩個線程,第一個線程拿到offset是1,然後開始寫入,在寫入的過程中,第二個線程也去拿offset,因為對於一個文件來說多個線程是共享同一個struct file結構,因此拿到的offset仍然是1,這個時候線程1寫結束,更新offset,然後線程2開始寫。最後的結果顯而易見,線程2覆蓋了線程1的數據,通過分析可知,多線程寫文件不是原子的,會產生數據覆蓋。但是否會產生數據錯亂,也就是數據交叉寫入了?其實這種情況是不會發生的,至於為什麼請看下麵這段代碼:

ssize_t generic_file_aio_write(struct kiocb *iocb, const struct iovec *iov,
        unsigned long nr_segs, loff_t pos)
{
    struct file *file = iocb->ki_filp;
    struct inode *inode = file->f_mapping->host;
    struct blk_plug plug;
    ssize_t ret;

    BUG_ON(iocb->ki_pos != pos);
    // 文件的寫入其實是加鎖的
    mutex_lock(&inode->i_mutex);
    blk_start_plug(&plug);
    ret = __generic_file_aio_write(iocb, iov, nr_segs, &iocb->ki_pos);
    mutex_unlock(&inode->i_mutex);

    if (ret > 0 || ret == -EIOCBQUEUED) {
        ssize_t err;

        err = generic_write_sync(file, pos, ret);
        if (err < 0 && ret > 0)
            ret = err;
    }
    blk_finish_plug(&plug);
    return ret;
}
EXPORT_SYMBOL(generic_file_aio_write);

​   所以並不會產生數據錯亂,隻會存在數據覆蓋的問題,既然如此我們在實際的進行文件讀寫的時候是否需要進行加鎖呢? 加鎖的確是可以解決問題的,但是在這裏未免有點牛刀殺雞的感覺,好在OS給我們提供了原子寫入的方法,第一種就是在打開文件的時候添加**O_APPEND**標誌,通過**O_APPEND**標誌將獲取文件的offset和文件寫入放在一起用鎖進行了保護,使得這兩步是原子的,具體代碼可以看上麵代碼中的__generic_file_aio_write函數。


ssize_t __generic_file_aio_write(struct kiocb *iocb, const struct iovec *iov,
                 unsigned long nr_segs, loff_t *ppos)
{
    struct file *file = iocb->ki_filp;
    struct address_space * mapping = file->f_mapping;
    size_t ocount;      /* original count */
    size_t count;       /* after file limit checks */
    struct inode    *inode = mapping->host;
    loff_t      pos;
    ssize_t     written;
    ssize_t     err;

    ocount = 0;
    err = generic_segment_checks(iov, &nr_segs, &ocount, VERIFY_READ);
    if (err)
        return err;

    count = ocount;
    pos = *ppos;

    vfs_check_frozen(inode->i_sb, SB_FREEZE_WRITE);

    /* We can write back this queue in page reclaim */
    current->backing_dev_info = mapping->backing_dev_info;
    written = 0;
    // 重點就在這個函數
    err = generic_write_checks(file, &pos, &count, S_ISBLK(inode->i_mode));
    if (err)
        goto out;
    ......// 省略
}

inline int generic_write_checks(struct file *file, loff_t *pos, size_t *count, int isblk)
{
    struct inode *inode = file->f_mapping->host;
    unsigned long limit = rlimit(RLIMIT_FSIZE);

        if (unlikely(*pos < 0))
                return -EINVAL;

    if (!isblk) {
        /* FIXME: this is for backwards compatibility with 2.4 */
        // 如果帶有O_APPEND標誌,會直接拿到文件的大小,設置為新的offset
        if (file->f_flags & O_APPEND)
                        *pos = i_size_read(inode);

        if (limit != RLIM_INFINITY) {
            if (*pos >= limit) {
                send_sig(SIGXFSZ, current, 0);
                return -EFBIG;
            }
            if (*count > limit - (typeof(limit))*pos) {
                *count = limit - (typeof(limit))*pos;
            }
        }
    }
    ......// 省略
}

​   通過上麵的代碼可知,如果帶有**O_APPEND**標誌的情況,在文件真正寫入之前會調用generic_write_checks進行一些檢查,在檢查的時候如果發現帶有**O_APPEND**標誌就將offset設置為文件的大小。而這整個過程都是在加鎖的情況下完成的,所以帶有**O_APPEND**標誌的情況下,文件的寫入是原子的,多線程寫文件是不會導致數據錯亂的。另外一種情況就是**pwrite**係統調用,**pwrite**係統調用通過讓用戶指定寫入的offset,值得整個寫入的過程天然的變成原子的了,在上文說到,整個寫入的過程是因為獲取offset和文件寫入是兩個獨立的步驟,並沒有加鎖,通過pwrite省去了獲取offset這一步,最終整個文件寫入隻有一步加鎖的文件寫入過程了。pwrite的代碼如下:

SYSCALL_DEFINE(pwrite64)(unsigned int fd, const char __user *buf,
             size_t count, loff_t pos)
{
    struct file *file;
    ssize_t ret = -EBADF;
    int fput_needed;

    if (pos < 0)
        return -EINVAL;

    file = fget_light(fd, &fput_needed);
    if (file) {
        ret = -ESPIPE;
        if (file->f_mode & FMODE_PWRITE)  
            // 直接把offset也就是pos傳遞進去,而普通的write需要
            // 需要先從struct file中拿到offset,然後傳遞進去
            ret = vfs_write(file, buf, count, &pos);
        fput_light(file, fput_needed);
    }

    return ret;
}

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
        size_t, count)
{
    struct file *file;
    ssize_t ret = -EBADF;
    int fput_needed;

    file = fget_light(fd, &fput_needed);
    if (file) {
        // 第一步拿offset
        loff_t pos = file_pos_read(file);
        // 第二步實際的寫入
        ret = vfs_write(file, buf, count, &pos);
        // 第三步寫回offset
        file_pos_write(file, pos);
        fput_light(file, fput_needed);
    }

    return ret;
}

​   最後一個問題是多個進程寫同一個文件是否會造成文件寫錯亂,直觀來說是多進程寫文件不是原子的,這是很顯而易見的,因為每個進程都擁有一個struct file對象,是獨立的,並且都擁有獨立的文件offset,所以很顯然這會導致上文中說到的數據覆蓋的情況,但是否會導致數據錯亂呢?,答案是不會,雖然**struct file**對象是獨立的,但是**struct inode**是共享的(相同的文件無論打開多少次都隻有一個**struct inode**對象),文件的最後寫入其實是先要寫入到頁緩存中,而頁緩存和**struct inode**是一一對應的關係,在實際文件寫入之前會加鎖,而這個鎖就是屬於**struct inode**對象(見上文中的mutex_lock(&inode->i_mutex))的,所有無論有多少個進程或者線程,隻要是對同一個文件寫數據,拿到的都是同一把鎖,是線程安全的,所以也不會出現數據寫錯亂的情況。

最後更新:2017-08-13 22:32:35

  上一篇:go  雲服務器 ECS 搭建WordPress網站:安裝 WordPress
  下一篇:go  看看這些醫療差距在哪裏?