Linux的文件系統及文件緩存知識點整理

https://www.luozhiyun.com/archives/291node

Linux的文件系統

文件系統的特色

  1. 文件系統要有嚴格的組織形式,使得文件可以以塊爲單位進行存儲。linux

  2. 文件系統中也要有索引區,用來方便查找一個文件分紅的多個塊都存放在了什麼位置。緩存

  3. 若是文件系統中有的文件是熱點文件,近期常常被讀取和寫入,文件系統應該有緩存層。安全

  4. 文件應該用文件夾的形式組織起來,方便管理和查詢。數據結構

  5. Linux內核要在本身的內存裏面維護一套數據結構,來保存哪些文件被哪些進程打開和使用。app

    整體來講,文件系統的主要功能梳理以下:異步

    img

ext系列的文件系統的格式

inode與塊的存儲

硬盤分紅相同大小的單元,咱們稱爲塊(Block)。一塊的大小是扇區大小的整數倍,默認是4K。在格式化的時候,這個值是能夠設定的。async

一大塊硬盤被分紅了一個個小的塊,用來存放文件的數據部分。這樣一來,若是咱們像存放一個文件,就不用給他分配一塊連續的空間了。咱們能夠分散成一個個小塊進行存放。這樣就靈活得多,也比較容易添加、刪除和插入數據。函數

inode就是文件索引的意思,咱們每一個文件都會對應一個inode;一個文件夾就是一個文件,也對應一個inode。性能

inode數據結構以下:

struct ext4_inode {
	__le16	i_mode;		/* File mode */
	__le16	i_uid;		/* Low 16 bits of Owner Uid */
	__le32	i_size_lo;	/* Size in bytes */
	__le32	i_atime;	/* Access time */
	__le32	i_ctime;	/* Inode Change time */
	__le32	i_mtime;	/* Modification time */
	__le32	i_dtime;	/* Deletion Time */
	__le16	i_gid;		/* Low 16 bits of Group Id */
	__le16	i_links_count;	/* Links count */
	__le32	i_blocks_lo;	/* Blocks count */
	__le32	i_flags;	/* File flags */
......
	__le32	i_block[EXT4_N_BLOCKS];/* Pointers to blocks */
	__le32	i_generation;	/* File version (for NFS) */
	__le32	i_file_acl_lo;	/* File ACL */
	__le32	i_size_high;
......
};

inode裏面有文件的讀寫權限i_mode,屬於哪一個用戶i_uid,哪一個組i_gid,大小是多少i_size_io,佔用多少個塊i_blocks_io,i_atime是access time,是最近一次訪問文件的時間;i_ctime是change time,是最近一次更改inode的時間;i_mtime是modify time,是最近一次更改文件的時間等。

全部的文件都是保存在i_block裏面。具體保存規則由EXT4_N_BLOCKS決定,EXT4_N_BLOCKS有以下的定義:

#define	EXT4_NDIR_BLOCKS		12
#define	EXT4_IND_BLOCK			EXT4_NDIR_BLOCKS
#define	EXT4_DIND_BLOCK			(EXT4_IND_BLOCK + 1)
#define	EXT4_TIND_BLOCK			(EXT4_DIND_BLOCK + 1)
#define	EXT4_N_BLOCKS			(EXT4_TIND_BLOCK + 1)

在ext2和ext3中,其中前12項直接保存了塊的位置,也就是說,咱們能夠經過i_block[0-11],直接獲得保存文件內容的塊。

img

可是,若是一個文件比較大,12塊放不下。當咱們用到i_block[12]的時候,就不能直接放數據塊的位置了,要否則i_block很快就會用完了。

那麼可讓i_block[12]指向一個塊,這個塊裏面不放數據塊,而是放數據塊的位置,這個塊咱們稱爲間接塊。若是文件再大一些,i_block[13]會指向一個塊,咱們能夠用二次間接塊。二次間接塊裏面存放了間接塊的位置,間接塊裏面存放了數據塊的位置,數據塊裏面存放的是真正的數據。若是文件再大點,那麼i_block[14]同理。

這裏面有一個很是顯著的問題,對於大文件來說,咱們要屢次讀取硬盤才能找到相應的塊,這樣訪問速度就會比較慢。

爲了解決這個問題,ext4作了必定的改變。它引入了一個新的概念,叫做Extents。比方說,一個文件大小爲128M,若是使用4k大小的塊進行存儲,須要32k個塊。若是按照ext2或者ext3那樣散着放,數量太大了。可是Extents能夠用於存放連續的塊,也就是說,咱們能夠把128M放在一個Extents裏面。這樣的話,對大文件的讀寫性能提升了,文件碎片也減小了。

Exents是一個樹狀結構:

img

每一個節點都有一個頭,ext4_extent_header能夠用來描述某個節點。

struct ext4_extent_header {
	__le16	eh_magic;	/* probably will support different formats */
	__le16	eh_entries;	/* number of valid entries */
	__le16	eh_max;		/* capacity of store in entries */
	__le16	eh_depth;	/* has tree real underlying blocks? */
	__le32	eh_generation;	/* generation of the tree */
};

eh_entries表示這個節點裏面有多少項。這裏的項分兩種,若是是葉子節點,這一項會直接指向硬盤上的連續塊的地址,咱們稱爲數據節點ext4_extent;若是是分支節點,這一項會指向下一層的分支節點或者葉子節點,咱們稱爲索引節點ext4_extent_idx。這兩種類型的項的大小都是12個byte。

/*
 * This is the extent on-disk structure.
 * It's used at the bottom of the tree.
 */
struct ext4_extent {
	__le32	ee_block;	/* first logical block extent covers */
	__le16	ee_len;		/* number of blocks covered by extent */
	__le16	ee_start_hi;	/* high 16 bits of physical block */
	__le32	ee_start_lo;	/* low 32 bits of physical block */
};
/*
 * This is index on-disk structure.
 * It's used at all the levels except the bottom.
 */
struct ext4_extent_idx {
	__le32	ei_block;	/* index covers logical blocks from 'block' */
	__le32	ei_leaf_lo;	/* pointer to the physical block of the next *
				 * level. leaf or next index could be there */
	__le16	ei_leaf_hi;	/* high 16 bits of physical block */
	__u16	ei_unused;
};

若是文件不大,inode裏面的i_block中,能夠放得下一個ext4_extent_header和4項ext4_extent。因此這個時候,eh_depth爲0,也即inode裏面的就是葉子節點,樹高度爲0。

若是文件比較大,4個extent放不下,就要分裂成爲一棵樹,eh_depth>0的節點就是索引節點,其中根節點深度最大,在inode中。最底層eh_depth=0的是葉子節點。

除了根節點,其餘的節點都保存在一個塊4k裏面,4k扣除ext4_extent_header的12個byte,剩下的可以放340項,每一個extent最大能表示128MB的數據,340個extent會使你的表示的文件達到42.5GB。

inode位圖和塊位圖

inode的位圖大小爲4k,每一位對應一個inode。若是是1,表示這個inode已經被用了;若是是0,則表示沒被用。block的位圖同理。

在Linux操做系統裏面,想要建立一個新文件,會調用open函數,而且參數會有O_CREAT。這表示當文件找不到的時候,咱們就須要建立一個。那麼open函數的調用過程大體是:要打開一個文件,先要根據路徑找到文件夾。若是發現文件夾下面沒有這個文件,同時又設置了O_CREAT,就說明咱們要在這個文件夾下面建立一個文件。

建立一個文件,那麼就須要建立一個inode,那麼就會從文件系統裏面讀取inode位圖,而後找到下一個爲0的inode,就是空閒的inode。對於block位圖,在寫入文件的時候,也會有這個過程。

文件系統的格式

數據塊的位圖是放在一個塊裏面的,共4k。每位表示一個數據塊,共能夠表示$4 * 1024 * 8 = 2{15}$個數據塊。若是每一個數據塊也是按默認的4K,最大能夠表示空間爲$2{15} * 4 * 1024 = 2^{27}$個byte,也就是128M,那麼顯然是不夠的。

這個時候就須要用到塊組,數據結構爲ext4_group_desc,這裏面對於一個塊組裏的inode位圖bg_inode_bitmap_lo、塊位圖bg_block_bitmap_lo、inode列表bg_inode_table_lo,都有相應的成員變量。

這樣一個個塊組,就基本構成了咱們整個文件系統的結構。由於塊組有多個,塊組描述符也一樣組成一個列表,咱們把這些稱爲塊組描述符表。

咱們還須要有一個數據結構,對整個文件系統的狀況進行描述,這個就是超級塊ext4_super_block。裏面有整個文件系統一共有多少inode,s_inodes_count;一共有多少塊,s_blocks_count_lo,每一個塊組有多少inode,s_inodes_per_group,每一個塊組有多少塊,s_blocks_per_group等。這些都是這類的全局信息。

最終,整個文件系統格式就是下面這個樣子。

img

默認狀況下,超級塊和塊組描述符表都有副本保存在每個塊組裏面。防止這些數據丟失了,致使整個文件系統都打不開了。

因爲若是每一個塊組裏面都保存一份完整的塊組描述符表,一方面很浪費空間;另外一個方面,因爲一個塊組最大128M,而塊組描述符表裏面有多少項,這就限制了有多少個塊組,128M * 塊組的總數目是整個文件系統的大小,就被限制住了。

所以引入Meta Block Groups特性。

首先,塊組描述符表不會保存全部塊組的描述符了,而是將塊組分紅多個組,咱們稱爲元塊組(Meta Block Group)。每一個元塊組裏面的塊組描述符表僅僅包括本身的,一個元塊組包含64個塊組,這樣一個元塊組中的塊組描述符表最多64項。

咱們假設一共有256個塊組,原來是一個整的塊組描述符表,裏面有256項,要備份就全備份,如今分紅4個元塊組,每一個元塊組裏面的塊組描述符表就只有64項了,這就小多了,並且四個元塊組本身備份本身的。

img

根據圖中,每個元塊組包含64個塊組,塊組描述符表也是64項,備份三份,在元塊組的第一個,第二個和最後一個塊組的開始處。

若是開啓了sparse_super特性,超級塊和塊組描述符表的副本只會保存在塊組索引爲0、三、五、7的整數冪裏。因此上圖的超級塊只在索引爲0、三、五、7等的整數冪裏。

目錄的存儲格式

其實目錄自己也是個文件,也有inode。inode裏面也是指向一些塊。和普通文件不一樣的是,普通文件的塊裏面保存的是文件數據,而目錄文件的塊裏面保存的是目錄裏面一項一項的文件信息。這些信息咱們稱爲ext4_dir_entry。

在目錄文件的塊中,最簡單的保存格式是列表,每一項都會保存這個目錄的下一級的文件的文件名和對應的inode,經過這個inode,就能找到真正的文件。第一項是「.」,表示當前目錄,第二項是「…」,表示上一級目錄,接下來就是一項一項的文件名和inode。

若是在inode中設置EXT4_INDEX_FL標誌,那麼就表示根據索引查找文件。索引項會維護一個文件名的哈希值和數據塊的一個映射關係。

若是咱們要查找一個目錄下面的文件名,能夠經過名稱取哈希。若是哈希可以匹配上,就說明這個文件的信息在相應的塊裏面。而後打開這個塊,若是裏面再也不是索引,而是索引樹的葉子節點的話,那裏面仍是ext4_dir_entry的列表,咱們只要一項一項找文件名就行。經過索引樹,咱們能夠將一個目錄下面的N多的文件分散到不少的塊裏面,能夠很快地進行查找。

img

Linux中的文件緩存

ext4文件系統層

對於ext4文件系統來說,內核定義了一個ext4_file_operations。

const struct file_operations ext4_file_operations = {
......
	.read_iter	= ext4_file_read_iter,
	.write_iter	= ext4_file_write_iter,
......
}

ext4_file_read_iter會調用generic_file_read_iter,ext4_file_write_iter會調用__generic_file_write_iter。

ssize_t
generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
......
    if (iocb->ki_flags & IOCB_DIRECT) {
......
        struct address_space *mapping = file->f_mapping;
......
        retval = mapping->a_ops->direct_IO(iocb, iter);
    }
......
    retval = generic_file_buffered_read(iocb, iter, retval);
}


ssize_t __generic_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
......
    if (iocb->ki_flags & IOCB_DIRECT) {
......
        written = generic_file_direct_write(iocb, from);
......
    } else {
......
		written = generic_perform_write(file, from, iocb->ki_pos);
......
    }
}

generic_file_read_iter和__generic_file_write_iter有類似的邏輯,就是要區分是否用緩存。所以,根據是否使用內存作緩存,咱們能夠把文件的I/O操做分爲兩種類型。

第一種類型是緩存I/O。大多數文件系統的默認I/O操做都是緩存I/O。對於讀操做來說,操做系統會先檢查,內核的緩衝區有沒有須要的數據。若是已經緩存了,那就直接從緩存中返回;不然從磁盤中讀取,而後緩存在操做系統的緩存中。對於寫操做來說,操做系統會先將數據從用戶空間複製到內核空間的緩存中。這時對用戶程序來講,寫操做就已經完成。至於何時再寫到磁盤中由操做系統決定,除非顯式地調用了sync同步命令。

第二種類型是直接IO,就是應用程序直接訪問磁盤數據,而不通過內核緩衝區,從而減小了在內核緩存和用戶程序之間數據複製。

若是在寫的邏輯__generic_file_write_iter裏面,發現設置了IOCB_DIRECT,則調用generic_file_direct_write,裏面一樣會調用address_space的direct_IO的函數,將數據直接寫入硬盤。

帶緩存的寫入操做

咱們先來看帶緩存寫入的函數generic_perform_write。

ssize_t generic_perform_write(struct file *file,
				struct iov_iter *i, loff_t pos)
{
	struct address_space *mapping = file->f_mapping;
	const struct address_space_operations *a_ops = mapping->a_ops;
	do {
		struct page *page;
		unsigned long offset;	/* Offset into pagecache page */
		unsigned long bytes;	/* Bytes to write to page */
		status = a_ops->write_begin(file, mapping, pos, bytes, flags,
						&page, &fsdata);
		copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
		flush_dcache_page(page);
		status = a_ops->write_end(file, mapping, pos, bytes, copied,
						page, fsdata);
		pos += copied;
		written += copied;


		balance_dirty_pages_ratelimited(mapping);
	} while (iov_iter_count(i));
}

循環中主要作了這幾件事:

  • 對於每一頁,先調用address_space的write_begin作一些準備;
  • 調用iov_iter_copy_from_user_atomic,將寫入的內容從用戶態拷貝到內核態的頁中;
  • 調用address_space的write_end完成寫操做;
  • 調用balance_dirty_pages_ratelimited,看髒頁是否太多,須要寫回硬盤。所謂髒頁,就是寫入到緩存,可是尚未寫入到硬盤的頁面。

對於第一步,調用的是ext4_write_begin來講,主要作兩件事:

第一作日誌相關的工做。

ext4是一種日誌文件系統,是爲了防止忽然斷電的時候的數據丟失,引入了日誌(Journal)模式。日誌文件系統比非日誌文件系統多了一個Journal區域。文件在ext4中分兩部分存儲,一部分是文件的元數據,另外一部分是數據。元數據和數據的操做日誌Journal也是分開管理的。你能夠在掛載ext4的時候,選擇Journal模式。這種模式在將數據寫入文件系統前,必須等待元數據和數據的日誌已經落盤才能發揮做用。這樣性能比較差,可是最安全。

另外一種模式是order模式。這個模式不記錄數據的日誌,只記錄元數據的日誌,可是在寫元數據的日誌前,必須先確保數據已經落盤。這個折中,是默認模式。

還有一種模式是writeback,不記錄數據的日誌,僅記錄元數據的日誌,而且不保證數據比元數據先落盤。這個性能最好,可是最不安全。

第二調用grab_cache_page_write_begin來,獲得應該寫入的緩存頁。

struct page *grab_cache_page_write_begin(struct address_space *mapping,
					pgoff_t index, unsigned flags)
{
	struct page *page;
	int fgp_flags = FGP_LOCK|FGP_WRITE|FGP_CREAT;
	page = pagecache_get_page(mapping, index, fgp_flags,
			mapping_gfp_mask(mapping));
	if (page)
		wait_for_stable_page(page);
	return page;
}

在內核中,緩存以頁爲單位放在內存裏面,每個打開的文件都有一個struct file結構,每一個struct file結構都有一個struct address_space用於關聯文件和內存,就是在這個結構裏面,有一棵樹,用於保存全部與這個文件相關的的緩存頁。

對於第二步,調用iov_iter_copy_from_user_atomic。先將分配好的頁面調用kmap_atomic映射到內核裏面的一個虛擬地址,而後將用戶態的數據拷貝到內核態的頁面的虛擬地址中,調用kunmap_atomic把內核裏面的映射刪除。

size_t iov_iter_copy_from_user_atomic(struct page *page,
		struct iov_iter *i, unsigned long offset, size_t bytes)
{
	char *kaddr = kmap_atomic(page), *p = kaddr + offset;
	iterate_all_kinds(i, bytes, v,
		copyin((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len),
		memcpy_from_page((p += v.bv_len) - v.bv_len, v.bv_page,
				 v.bv_offset, v.bv_len),
		memcpy((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len)
	)
	kunmap_atomic(kaddr);
	return bytes;
}

第三步中,調用ext4_write_end完成寫入。這裏面會調用ext4_journal_stop完成日誌的寫入,會調用block_write_end->__block_commit_write->mark_buffer_dirty,將修改過的緩存標記爲髒頁。能夠看出,其實所謂的完成寫入,並無真正寫入硬盤,僅僅是寫入緩存後,標記爲髒頁

第四步,調用 balance_dirty_pages_ratelimited,是回寫髒頁。

/**
 * balance_dirty_pages_ratelimited - balance dirty memory state
 * @mapping: address_space which was dirtied
 *
 * Processes which are dirtying memory should call in here once for each page
 * which was newly dirtied.  The function will periodically check the system's
 * dirty state and will initiate writeback if needed.
  */
void balance_dirty_pages_ratelimited(struct address_space *mapping)
{
	struct inode *inode = mapping->host;
	struct backing_dev_info *bdi = inode_to_bdi(inode);
	struct bdi_writeback *wb = NULL;
	int ratelimit;
......
	if (unlikely(current->nr_dirtied >= ratelimit))
		balance_dirty_pages(mapping, wb, current->nr_dirtied);
......
}

在balance_dirty_pages_ratelimited裏面,發現髒頁的數目超過了規定的數目,就調用balance_dirty_pages->wb_start_background_writeback,啓動一個背後線程開始回寫。

另外還有幾種場景也會觸發回寫:

  • 用戶主動調用sync,將緩存刷到硬盤上去,最終會調用wakeup_flusher_threads,同步髒頁;
  • 當內存十分緊張,以致於沒法分配頁面的時候,會調用free_more_memory,最終會調用wakeup_flusher_threads,釋放髒頁;
  • 髒頁已經更新了較長時間,時間上超過了設定時間,須要及時回寫,保持內存和磁盤上數據一致性。

帶緩存的讀操做

看帶緩存的讀,對應的是函數generic_file_buffered_read。

static ssize_t generic_file_buffered_read(struct kiocb *iocb,
		struct iov_iter *iter, ssize_t written)
{
	struct file *filp = iocb->ki_filp;
	struct address_space *mapping = filp->f_mapping;
	struct inode *inode = mapping->host;
	for (;;) {
		struct page *page;
		pgoff_t end_index;
		loff_t isize;
		page = find_get_page(mapping, index);
		if (!page) {
			if (iocb->ki_flags & IOCB_NOWAIT)
				goto would_block;
			page_cache_sync_readahead(mapping,
					ra, filp,
					index, last_index - index);
			page = find_get_page(mapping, index);
			if (unlikely(page == NULL))
				goto no_cached_page;
		}
		if (PageReadahead(page)) {
			page_cache_async_readahead(mapping,
					ra, filp, page,
					index, last_index - index);
		}
		/*
		 * Ok, we have the page, and it's up-to-date, so
		 * now we can copy it to user space...
		 */
		ret = copy_page_to_iter(page, offset, nr, iter);
    }
}

在generic_file_buffered_read函數中,咱們須要先找到page cache裏面是否有緩存頁。若是沒有找到,不但讀取這一頁,還要進行預讀,這須要在page_cache_sync_readahead函數中實現。預讀完了之後,再試一把查找緩存頁。

若是第一次找緩存頁就找到了,咱們仍是要判斷,是否是應該繼續預讀;若是須要,就調用page_cache_async_readahead發起一個異步預讀。

最後,copy_page_to_iter會將內容從內核緩存頁拷貝到用戶內存空間。

相關文章
相關標籤/搜索