歡迎光臨
每天分享高質量文章

關於檔案寫入的原子性討論

        檔案的寫入是否是原子的?多個執行緒寫入同一個檔案是否會寫錯亂?多個行程寫入同一個檔案是否會寫錯亂?想必這些問題多多少少會對我們產生一定的困擾,即使知道結果,很多時候也很難將這其中的原理清晰的表達給提問者,侯捷曾說過,原始碼面前,了無秘密,那麼本文也希望從原始碼的角度分析上述問題。在開始之前我們需要補充一下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物件來表示,該物件結構如下:    

// 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))的,所有無論有多少個行程或者執行緒,只要是對同一個檔案寫資料,拿到的都是同一把鎖,是執行緒安全的,所以也不會出現資料寫錯亂的情況。

往期精彩回顧
張義飛: Container技術之cgroup入門
mount namespace和共享子樹
Linux身份鑒別機制概述

贊(0)

分享創造快樂