高級I/O包含了很多內容,本篇會首先解釋下幾個相關的概念:阻塞、非阻塞、同步、異步等的概念;接着介紹下 Linux I/O 操作的具體過程;最後討論下多路複用、記錄鎖等幾個相關函數。
一、概念說明
Linux 的每個進程都是擁有自己的虛擬內存空間的,而一個進程的虛擬內存空間分爲內核空間和用戶空間兩個部分,當進程的執行過程中期待的某種事情沒有發生:請求系統資源失敗、等待某種操作的完成、新數據尚未到達等。對於一次IO訪問(以read舉例),數據會先被拷貝到操作系統內核的緩衝區中,然後纔會從操作系統內核的緩衝區拷貝到應用程序的地址空間。所以說,當一個read操作發生時,它會經歷兩個階段:
- 等待數據準備 (Waiting for the data to be ready)
- 將數據從內核拷貝到進程中 (Copying the data from the kernel to the process)
正式因爲這兩個階段,Linux系統產生了下面五種I/O:
1. 阻塞式I/O
通常來說,從普通文件讀數據,無論你是採用 fscanf
、 fgets
也好,read
也好,一定會在有限的時間內返回。但是如果你從設備,比如終端(標準輸入設備)讀數據,只要沒有遇到換行符(\n
),read
一定會堵在那而不返回。還有比如從網絡讀數據,如果網絡一直沒有數據到來,read
函數也會一直堵在那而不返回。
read
的這種行爲,稱之爲 block,一旦發生 block,本進程將會被操作系統投入睡眠,直到等待的事件發生了(比如有數據到來),進程纔會被喚醒。
系統調用 write
同樣有可能被阻塞,比如向網絡寫入數據,如果對方一直不接收,本端的緩衝區一旦被寫滿,就會被阻塞。
2. 非阻塞式I/O
當用戶進程發出 read
操作時,如果 kernel 中的數據還沒有準備好,那麼它並不會 block 用戶進程,而是立刻返回一個 error。從用戶進程角度講 ,它發起一個 read
操作後,並不需要等待,而是馬上就得到了一個結果。用戶進程判斷結果是一個 error 時,它就知道數據還沒有準備好,於是它可以再次發送 read
操作。一旦 kernel 中的數據準備好了,並且又再次收到了用戶進程的 system call,那麼它馬上就將數據拷貝到了用戶內存,然後返回。
所以:
非阻塞式I/O的特點是用戶進程需要不斷的主動詢問 kernel 數據好了沒有。
阻塞非阻塞是文件本身的特性,不是系統調用read/write本身可以控制的。
3. I/O多路複用
I/O多路複用(IO multiplexing
)就是我們說的 select
、 poll
、 epoll
,指的是單個 process
可以同時處理多個 IO 操作。它的基本原理就是 select
、 poll
、 epoll
會不斷的輪詢所負責的所有 I/O,當某個 I/O 有數據到達了,就通知用戶進程。
所以:
I/O 多路複用的特點是通過一種機制一個進程能同時等待多個文件描述符,而這些文件描述符(套接字描述符)其中的任意一個進入讀就緒狀態,select()
函數就可以返回。
4. 異步I/O
當用戶進程發起 read
操作之後,立刻就可以開始去做其它的事。而另一方面,從 kernel 的角度,當它受到一個 asynchronous read 之後,首先它會立刻返回,所以不會對用戶進程產生任何 block。然後,kernel 會等待數據準備完成,然後將數據拷貝到用戶內存,當這一切都完成之後,kernel 會給用戶進程發送一個 signal,告訴它 read 操作完成了。
同步I/O 和 異步I/O 的區別:
兩者之間的區別就在於同步I/O 做 I/O操作的時候會將進程阻塞,所以,按照這個定義,之前的阻塞I/O、非阻塞I/O、I/O多路複用都屬於同步I/O。
非阻塞I/O 之所以屬於同步I/O 是因爲其非阻塞是因爲並沒有進行相應的IO操作,在其進行IO操作的時候,依舊是阻塞的。
- 信號驅動I/O
Linux 的 I/O 操作過程
1. GNU Linux I/O操作類別
Linux 的文件操作並不僅僅是對我們通常意義上的文件的讀寫,基於一切接文件的思想,Linux的I/O操作類別包含一下幾類:
- 文件及流的標準輸入輸出
- 底層輸入輸出
- 文件系統接口
- 管道及FIFO(先入先出隊列)
- Socket
- 底層終端接口(tty)
2. 主要數據結構介紹
FD
對於內核而言,所有打開文件都由文件描述符引用。
文件描述符是一個非負整數。當打開一個現存文件或創建一個新文件時,內核向進程返回一個文件描述符。當讀、寫一個文件時,用open
或creat
返回的文件描述符fd
標識該文件,將其作爲參數傳送給read
或write
。在 POSIX.1 應用程序中,文件描述符爲常數0
、1
和2
分別代表STDIN_FILENO
、STDOUT_FILENO
和STDERR_FILENO
,意即標準輸入,標準輸出和標準出錯輸出,這些常數都定義在頭文件<unistd.h>
中。文件描述符的範圍是0~OPEN_MAX
,在目前常用的linux系統中,是32位整形所能表示的整數,即65535,64位機上則更多。進程中文件相關結構體
struct file
結構體定義在include/linux/fs.h
中。該結構體代表一個打開的文件,系統中每一個打開的文件在內核空間中都有一個關聯的struct file
。它由內核在打開文件的時候創建,並傳遞給在該文件上進行操作的熱河函數,在該文件的所有實例都關閉後,內核釋放該數據結構。
C
struct file {
union {
struct list_head fu_list; //文件對象鏈表指針linux/include/linux/list.h
struct rcu_head fu_rcuhead; //RCU(Read-Copy Update)是Linux 2.6內核中新的鎖機制
} f_u;
struct path f_path; //包含dentry和mnt兩個成員,用於確定文件路徑
#define f_dentry f_path.dentry //f_path的成員之一,當前文件的dentry結構
#define f_vfsmnt f_path.mnt //表示當前文件所在文件系統的掛載根目錄
const struct file_operations *f_op; //與該文件相關聯的操作函數
atomic_t f_count; //文件的引用計數(有多少進程打開該文件)
unsigned int f_flags; //對應於open時指定的flag
mode_t f_mode; //讀寫模式:open的mod_t mode參數
off_t f_pos; //該文件在當前進程中的文件偏移量
struct fown_struct f_owner; //該結構的作用是通過信號進行I/O時間通知的數據。
unsigned int f_uid, f_gid; //文件所有者id,所有者組id
struct file_ra_state f_ra; //在linux/include/linux/fs.h中定義,文件預讀相關
unsigned long f_version;
#ifdef CONFIG_SECURITY
void *f_security;
#endif
void *private_data;
#ifdef CONFIG_EPOLL
struct list_head f_ep_links;
spinlock_t f_ep_lock;
#endif
struct address_space *f_mapping;
};
struct dentry
dentry
是 Linux 文件系統中某個索引節點(inode
)的鏈接。inode
對應於物理磁盤上的具體對象,dentry
是一個內存上的實體,其中的d_inode
指向對應的inode
。一個inode
可以在運行的時候鏈接多個dentry
,而d_count
記錄了鏈接的具體數量。
C
struct dentry {
atomic_t d_count; //目錄項對象使用計數器,可以有未使用態,使用態和負狀態
unsigned int d_flags; //目錄項標誌
struct inode * d_inode; //與文件名關聯的索引節點
struct dentry * d_parent; //父目錄的目錄項對象
struct list_head d_hash; //散列表表項的指針
struct list_head d_lru; //未使用鏈表的指針
struct list_head d_child; //父目錄中目錄項對象的鏈表的指針
struct list_head d_subdirs; //對目錄而言,表示子目錄目錄項對象的鏈表
struct list_head d_alias; //相關索引節點(別名)的鏈表
int d_mounted; //對於安裝點而言,表示被安裝文件系統根項
struct qstr d_name; //文件名
unsigned long d_time;
struct dentry_operations *d_op; //目錄項方法
struct super_block *d_sb; //文件的超級塊對象
vunsigned long d_vfs_flags;
void *d_fsdata; //與文件系統相關的數據
unsigned char d_iname [DNAME_INLINE_LEN]; //存放短文件名
};struct files_struct
對於每個進程,包含一個files_struct
結構,用來記錄文件描述符的使用情況。
C
struct files_struct
{
atomic_t count; //使用該表的進程數
struct fdtable *fdt;
struct fdtable fdtab;
spinlock_t file_lock ____cacheline_aligned_in_smp;
int next_fd; //數值最小的最近關閉文件的文件描述符,下一個可用的文件描述符
struct embedded_fd_set close_on_exec_init; //執行exec時需要關閉的文件描述符初值集合 struct embedded_fd_set open_fds_init; //文件描述符的屏蔽字初值集合
struct file *fd_array[NR_OPEN_DEFAULT]; 默認打開的fd隊列
};
struct fdtable {
unsigned int max_fds;
struct file **fd; //指向打開的文件描述符列表的指針,開始的時候指向fd_array,
當超過max_fds時,重新分配地址
fd_set *close_on_exec; //執行exec需要關閉的文件描述符位圖(fork,exec即不被子進程繼承的文件描述符)
fd_set *open_fds; //打開的文件描述符位圖
struct rcu_head rcu;
struct fdtable *next;
};struct fs_struct
fs_struct
是文件系統相關信息結構體。
C
struct fs_struct {
atomic_t count; //共享表的進程個數
rwlock_t lock; //自旋鎖
int umask; //文件權限掩碼
struct dentry *root, //根目錄目錄項
*pwd, //當前目錄目錄項
*altroot; //模擬根目錄目錄項
struct vfsmount *rootmnt, //根目錄文件系統對象
*pwdmnt, //
*altrootmnt;//
};
每個進程都有一個
task_struct
結構體,其中包含了一個fs_struct
和一個files_struct
結構體,其中files_struct
中的fd_array
記錄了所有該進程打開的文件的file
結構體,每個file
結構體中的f_entry
指向了當前文件的dentry
結構體,debtry
結構體實際指向了相應的文件inode
。inode
inode包含文件的元信息,具體來說有以下內容:- 文件的字節數 文件的字節數
- 文件擁有者的 文件擁有者的User ID
- 文件的 文件的Group ID
- 文件的讀、寫、執行權限 文件的讀、寫、執行權限
- 文件的時間戳,共有三個: 文件的時間戳,共有三個:
ctime指 指inode上一次變動的時間, 上一次變動的時間,
mtime指文件內容上一次變動的時間, 指文件內容上一次變動的時間,
atime指文件上一 指文件上一 次打開的時間。 次打開的時間。 - 鏈接數,即有多少文件名指向這個 鏈接數,即有多少文件名指向這個inode
- 文件數據 文件數據block的位置
3. I/O操作過程
打開文件
一個應用程序通過要求內核打開相應文件,宣告他要訪問一個I/O設備 ,內核返回一個非負整數,叫描述符號(Descriptor);改變文件位置
對於每個打開的文件,內核保持一個文件位置k,初始爲0,這個文件位置是從文件頭開始的偏移量。通過執行seek操作,顯式地設置當前位置爲k讀寫文件
讀:從文件拷貝n>0個字節到存儲器,寫:從存儲器拷貝n>0字節到文件關閉文件
通知內核關閉文件,作爲響應,內核釋放文件打開時創建的數據結構
三、記錄鎖
記錄鎖解決的是多個進程共同操作一個文件的問題,記錄鎖分爲兩種:
* 建議性鎖:建議性鎖要求每個相關程序在訪問文件前檢查是否有鎖存在,並尊重已有的鎖。
* 強制性鎖:強制性鎖是由內核執行的鎖,當一個文件被上鎖進行寫操作時,內核將阻止任何其它的程序進行該文件的讀寫操作。
我們通常使用的是強制性鎖,強制性鎖的上鎖函數是:
C
int fcntl(int fd, int cmd, ...);
第一個參數 fd
顯然指的是需要操作的文件描述符,第二個參數 cmd
是 F_GETLK
/ F_SETLK
/ F_SETLKW
,當進行鎖操作的時候,第三個參數是一個指向 flock
結構的指針。
C
struct flock {
short l_type; //希望的鎖類型 F_RDLCK(讀鎖) F_WRLCK(寫鎖) F_UNLCK(解鎖)
short l_whence; //區域的起始位置 SEEK_SET SEEK_CUR SEEK_END
off_t l_start; //區域的起始字節
off_t l_len; //區域的字節長度
pid_t l_pid; //持有鎖的進程IO
}
當 cmd
是 F_GETLK
時,函數會檢查當前鎖是否能夠創建,如果可以創建,則將 1_type
設置爲 F_UNLCK
,否則則將當前鎖的信息重寫。
當 cmd
是 F_SETLK
時,設置相應的鎖,如果不能創建,返回失敗代碼。
當 cmd
是 F_GETLK
時,如果當前設置的鎖無法設置,則休眠等待鎖創建。
記錄鎖的幾個注意點:
- 檢查鎖是否存在,和加鎖過程並不是原子操作,所以,當檢查當前鎖不存在後加鎖,依舊有可能會失敗。
- 如果兩個進程相互等待對方持有並不釋放(鎖定)的資源時,造成死鎖。
- 當進程終止時,它所建立的所有鎖釋放
- 當文件描述符關閉的時候,該文件描述符上的所有鎖釋放
fork
不繼承任何鎖。
四、I/O複用
I/O多路複用就是通過一種機制,一個進程可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程序進行相應的讀寫操作。
1. select
C
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select
函數監視的文件描述符分3類,分別是 writefds
、readfds
、和 exceptfds
。調用後 select
函數會阻塞,直到有描述副就緒(有數據 可讀、可寫、或者有except),或者超時(timeout
指定等待時間,如果立即返回設爲null即可),函數返回。當 select
函數返回後,可以 通過遍歷 fdset
,來找到就緒的描述符。
函數的第一個參數 n
指的是最大描述符編號加1,即需要監視的3類文件描述符中的最大值加1.
函數中間的三個參數指函數監視的3類文件描述符的集合,分別是 writefds
(可寫)、readfds
(可寫)、和 exceptfds
(處於異常)。有幾個相應的接口可以設置這三個集合:
C
int FD_ISSET(int fd, fd_set *fdset); //測試描述符集中的某一位是否開啓
int FD_CLR(int fd, fd_set *fdset); //清除描述符集中的某一位
int FD_SET(int fd, fd_set *fdset); //開啓描述符集中的某一位
int FD_ZERO(fd_set *fdset); //清空描述符集中的所有位
函數的最後一個參數 timeout
指定等待的時間,當爲 NULL
的時候,一直等待;當爲 0 的時候,不等待。
函數有三個可能的返回值:
* 返回-1;表示出錯。
* 返回0;表示沒有描述符準備好。
* 一個正的返回值:表示已經準備好的描述符數。
已經準備好指的是:相應的讀寫沒有阻塞,或者某個描述符存在未決異常條件。
2. poll
C
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
不同與 select
使用三個位圖來表示三個 fdset
的方式,poll使用一個 pollfd的指針實現。
C
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};
pollfd
結構包含了要監視的 event 和發生的 event,不再使用 select
參數-值傳遞的方式。
pollfd
並沒有最大數量限制(但是數量過大後性能也是會下降)。timeout
參數只當了我們等待的時間,爲-1表示永遠等待,爲0表示不等待,爲正表示等待的時間(毫秒)。
和 select
函數一樣,poll
返回後,需要輪詢 pollfd
來獲取就緒的描述符。
3. epoll
epoll
是在2.6內核中提出的,是之前的 select
和 poll
的增強版本。相對於 select
和 poll
來說,epoll
更加靈活,沒有描述符限制。epoll
使用一個文件描述符管理多個描述符,將用戶關係的文件描述符的事件存放到內核的一個事件表中,這樣在用戶空間和內核空間的 copy 只需一次。
epoll
操作過程需要三個接口,分別如下:
C
int epoll_create(int size); //創建一個epoll的句柄,size用來告訴內核這個監聽的數目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
* int epoll_create(int size);
創建一個 epoll
的句柄,size
用來告訴內核這個監聽的數目一共有多大,這個參數不同於 select()
中的第一個參數,參數 size
並不是限制了epoll所能監聽的描述符最大個數,只是對內核初始分配內部數據結構的一個建議。
當創建好 epoll
句柄後,它就會佔用一個fd值,在 Linux 下如果查看 /proc/進程id/fd/
,是能夠看到這個 fd
的,所以在使用完 epoll
後,必須調用 close()
關閉,否則可能導致fd被耗盡。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函數是對指定描述符fd
執行op
操作。
epfd
:是epoll_create()
的返回值。op
:表示 op 操作,用三個宏來表示:添加EPOLL_CTL_ADD
,刪除EPOLL_CTL_DEL
,修改EPOLL_CTL_MOD
。分別添加、刪除和修改對fd的監聽事件。fd
:是需要監聽的fd
(文件描述符)epoll_event
:是告訴內核需要監聽什麼事,struct epoll_event
結構如下:
C
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
events可以是以下幾個宏的集合:
EPOLLIN
:表示對應的文件描述符可以讀(包括對端SOCKET正常關閉);
EPOLLOUT
:表示對應的文件描述符可以寫;
EPOLLPRI
:表示對應的文件描述符有緊急的數據可讀(這裏應該表示有帶外數據到來);
EPOLLERR
:表示對應的文件描述符發生錯誤;
EPOLLHUP
:表示對應的文件描述符被掛斷;
EPOLLET
: 將EPOLL設爲邊緣觸發(Edge Triggered)模式,這是相對於水平觸發(Level Triggered)來說的。
EPOLLONESHOT
:只監聽一次事件,當監聽完這次事件之後,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL隊列裏
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待epfd
上的 I/O 事件,最多返回maxevents
個事件。
參數events
用來存儲從內核得到事件的集合,maxevents
告之內核這個events
有多大,這個maxevents
的值不能大於創建epoll_create()
時的size
,參數timeout
是超時時間(毫秒,0會立即返回,-1將不確定,也有說法說是永久阻塞)。該函數返回需要處理的事件數目,如返回0表示已超時。