.
.
.
.
.
目錄
(一) 一起學 Unix 環境高級編程 (APUE) 之 標准IO
(二) 一起學 Unix 環境高級編程 (APUE) 之 文件 IO
(三) 一起學 Unix 環境高級編程 (APUE) 之 文件和目錄
(四) 一起學 Unix 環境高級編程 (APUE) 之 系統數據文件和信息
(五) 一起學 Unix 環境高級編程 (APUE) 之 進程環境
(六) 一起學 Unix 環境高級編程 (APUE) 之 進程控制
(七) 一起學 Unix 環境高級編程 (APUE) 之 進程關系 和 守護進程
(八) 一起學 Unix 環境高級編程 (APUE) 之 信號
(九) 一起學 Unix 環境高級編程 (APUE) 之 線程
(十) 一起學 Unix 環境高級編程 (APUE) 之 線程控制
(十一) 一起學 Unix 環境高級編程 (APUE) 之 高級 IO
1.非阻塞 I/O
高級 IO 部分有個很重要的概念是:非阻塞 I/O
在14章之前,我們討論的所有函數都是阻塞的函數,例如 read(2) 函數讀取設備時,設備中如果沒有充足的數據,那麼 read(2) 函數就會阻塞等待,直到有數據可讀再返回。
當 IO 操作時出現了錯誤的時候,我們之前在討論信號的博文中提到過會出現假錯的情況。
那麼從學了非阻塞 I/O 為止我們一共遇到了兩種假錯的情況:
EINTR:被信號打斷,阻塞時會遇到。
EAGAIN:非阻塞形式操作失敗。
遇到這兩種假錯的時候我們需要重新再操作一次,所以通常對假錯的判斷是放在循環中的。
例如 read(2) 函數使用非阻塞方式讀取數據時,如果沒有讀取到數據,errno 為 EAGAIN,此時並不是說設備有問題或讀取失敗,只是表明采用的是非阻塞方式讀取而已。
阻塞與非阻塞是使用的同一套函數,flags 特殊要求指定為 O_NONBLOCK 就可以了。
下面我們舉個小栗子:(偽代碼)
1 fd = open("/etc/service", O_RDONLY | O_NONBLOCK); 2 /* if error */ 3 4 while (1) { 5 size = read(fd, buf, BUFSIZE); 6 if (size < 0) { 7 if (EAGAIN == errno) { 8 continue; 9 } 10 perror("read()"); 11 exit(1); 12 } 13 14 // do sth... 15 16 }
上面的小栗子, 首先在 open(2) 的時候使用特殊要求 O_NONBLOCK 指定以非阻塞形式打開文件。
當 read(2) 發生錯誤時要判斷是否為假錯,如果發生了假錯就再試一次,如果是真錯就做相應的異常處理。
2.有限狀態機
大家先考慮一個問題:把大象放到冰箱裡需要幾步?
1)打開冰箱門;
2)把大象放進去;
3)關閉冰箱門;
這就是解決這個問題的自然流程。
圖1 簡單流程與復雜流程
把一個問題的解決步驟(自然流程)擺出來發現是結構化的流程就是簡單流程,如果不是結構化的流程就是復雜流程。所有的網絡應用和需要與人交互的流程都是復雜流程。
結構化的流程就是作為人類的本能解決問題的思路。
在之前的博文中 LZ 提到過一個“口令隨機校驗”的策略大家還記得嗎?就是要求用戶必須連續兩次輸入正確的密碼才認為校驗通過。就算是這樣小的模塊也不會用一個單純的順序選擇流程把它完成,它一定是一個非結構化的流程。
有限狀態機就是程序設計的一種思路而已,大家剛開始接觸覺得難以理解,那是因為還沒有習慣這種設計思路。我們為什麼覺得像原先那種流程化的程序設計思路好用?那是因為被虐慣了,你曾經被迫習慣用計算機的思路來考慮問題而不是用作為人解決問題的本能步驟來考慮問題。有限狀態機就是讓你以作為人的本能的解決問題的方式來解決問題,當你習慣了有限狀態機的設計思想之後就不覺得這是什麼難以理解的東西了。
有限狀態機被設計出來的目的就是為了解決復雜流程的問題,所以更何況是簡單流程的問題也一樣能夠輕松的解決。
作為程序猿最怕的是什麼?
恐怕最怕的就是需求變更了吧。
為什麼要使用有限狀態機的設計思路呢?因為它能幫助我們從容的應對需求變更。
使用有限狀態機編程的程序在面對需求變更的時候往往僅需要修改幾條 case 語句就可以了,而沒有使用有限狀態機編程的程序面對需求變更往往要把大段的代碼推倒重來。
所以如果你掌握了有限狀態機的編程思想,那麼在很多情況下都可以相對輕松的解決問題,而且程序具有較好強的健壯性。
說了這麼多廢話,有限狀態機到底是什麼呢?
使用有限狀態機首先要把程序的需求分析出來(廢話,用什麼編程都得先分析需求),然後把程序中出現的各種狀態抽象出來制作成一張狀態機流程圖,然後根據這個流程圖把程序的框架搭建出來,接下來就是添枝加葉了。
下面我們通過一個栗子來說明有限狀態機的設計思想。
假如有如下需求:從設備 tty11 讀取輸入並輸出到 tty12 上,同樣從 tyy12 讀取輸入並輸出到 tty11 上。
首先我們把它的各種狀態抽象出來畫成一幅圖。
圖2 有限狀態機
每個狀態畫成一個圓形節點,每個節點延伸出來有多少條線就表示有多少種可能性。
這些節點拿到我們的程序中就變成了一條條 case 語句,下面我們看看使用代碼如何實現。
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <unistd.h> 4 #include <sys/types.h> 5 #include <sys/stat.h> 6 #include <fcntl.h> 7 #include <errno.h> 8 9 #define BUFSIZE 1024 10 #define TTY1 "/dev/tty11" 11 #define TTY2 "/dev/tty12" 12 13 /* 狀態機的各種狀態 */ 14 enum 15 { 16 STATE_R=1, 17 STATE_W, 18 STATE_Ex, 19 STATE_T 20 }; 21 22 /* 狀態機,根據不同的需求設計不同的成員 */ 23 struct fsm_st 24 { 25 int state; // 狀態機當前的狀態 26 int sfd; // 讀取的來源文件描述符 27 int dfd; // 寫入的目標文件描述符 28 char buf[BUFSIZE]; // 緩沖 29 int len; // 一次讀取到的實際數據量 30 int pos; // buf 的偏移量,用於記錄堅持寫夠 n 個字節時每次循環寫到了哪裡 31 char *errstr; // 錯誤消息 32 }; 33 34 /* 狀態機驅動 */ 35 static void fsm_driver(struct fsm_st *fsm) 36 { 37 int ret; 38 39 switch(fsm->state) 40 { 41 case STATE_R: // 讀態 42 fsm->len = read(fsm->sfd,fsm->buf,BUFSIZE); 43 if(fsm->len == 0) // 讀到了文件末尾,將狀態機推向 T態 44 fsm->state = STATE_T; 45 else if(fsm->len < 0) // 讀取出現異常 46 { 47 if(errno == EAGAIN) // 如果是假錯就推到 讀態,重新讀一次 48 fsm->state = STATE_R; 49 else // 如果是真錯就推到 異常態 50 { 51 fsm->errstr = "read()"; 52 fsm->state = STATE_Ex; 53 } 54 } 55 else // 成功讀取到了數據,將狀態機推到 寫態 56 { 57 fsm->pos = 0; 58 fsm->state = STATE_W; 59 } 60 break; 61 62 case STATE_W: // 寫態 63 ret = write(fsm->dfd,fsm->buf+fsm->pos,fsm->len); 64 if(ret < 0) // 寫入出現異常 65 { 66 if(errno == EAGAIN) // 如果是假錯就再次推到 寫態,重新再寫入一次 67 fsm->state = STATE_W; 68 else // 如果是真錯就推到 異常態 69 { 70 fsm->errstr = "write()"; 71 fsm->state = STATE_Ex; 72 } 73 } 74 else // 成功寫入了數據 75 { 76 fsm->pos += ret; 77 fsm->len -= ret; 78 if(fsm->len == 0) // 如果將讀到的數據完全寫出去了就將狀態機推向 讀態,開始下一輪讀取 79 fsm->state = STATE_R; 80 else // 如果沒有將讀到的數據完全寫出去,那麼狀態機依然推到 寫態,下次繼續寫入沒寫完的數據,實現“堅持寫夠 n 個字節” 81 fsm->state = STATE_W; 82 } 83 84 break; 85 86 case STATE_Ex: // 異常態,打印異常並將狀態機推到 T態 87 perror(fsm->errstr); 88 fsm->state = STATE_T; 89 break; 90 91 case STATE_T: // 結束態,在這個例子中結束態沒有什麼需要做的事情,所以空著 92 /*do sth */ 93 break; 94 default: // 程序很可能發生了溢出等不可預料的情況,為了避免異常擴大直接自殺 95 abort(); 96 } 97 98 } 99 100 /* 推動狀態機 */ 101 static void relay(int fd1,int fd2) 102 { 103 int fd1_save,fd2_save; 104 // 因為是讀 tty1 寫 tty2;讀 tty2 寫 tty1,所以這裡的兩個狀態機直接取名為 fsm12 和 fsm21 105 struct fsm_st fsm12,fsm21; 106 107 fd1_save = fcntl(fd1,F_GETFL); 108 // 使用狀態機操作 IO 一般都采用非阻塞的形式,避免狀態機被阻塞 109 fcntl(fd1,F_SETFL,fd1_save|O_NONBLOCK); 110 fd2_save = fcntl(fd2,F_GETFL); 111 fcntl(fd2,F_SETFL,fd2_save|O_NONBLOCK); 112 113 // 在啟動狀態機之前將狀態機推向 讀態 114 fsm12.state = STATE_R; 115 // 設置狀態機中讀寫的來源和目標,這樣狀態機的讀寫接口就統一了。在狀態機裡面不用管到底是 讀tty1 寫tty2 還是 讀tty2 寫tty1 了,它只需要知道是 讀src 寫des 就可以了。 116 fsm12.sfd = fd1; 117 fsm12.dfd = fd2; 118 119 // 同上 120 fsm21.state = STATE_R; 121 fsm21.sfd = fd2; 122 fsm21.dfd = fd1; 123 124 125 // 開始推狀態機,只要不是 T態 就一直推 126 while(fsm12.state != STATE_T || fsm21.state != STATE_T) 127 { 128 // 調用狀態機驅動函數,狀態機開始工作 129 fsm_driver(&fsm12); 130 fsm_driver(&fsm21); 131 } 132 133 fcntl(fd1,F_SETFL,fd1_save); 134 fcntl(fd2,F_SETFL,fd2_save); 135 136 } 137 138 int main() 139 { 140 int fd1,fd2; 141 142 // 假設這裡忘記將設備 tty1 以非阻塞的形式打開也沒關系,因為推動狀態機之前會重新設定文件描述符為非阻塞形式 143 fd1 = open(TTY1,O_RDWR); 144 if(fd1 < 0) 145 { 146 perror("open()"); 147 exit(1); 148 } 149 write(fd1,"TTY1\n",5); 150 151 fd2 = open(TTY2,O_RDWR|O_NONBLOCK); 152 if(fd2 < 0) 153 { 154 perror("open()"); 155 exit(1); 156 } 157 write(fd2,"TTY2\n",5); 158 159 160 relay(fd1,fd2); 161 162 163 close(fd1); 164 close(fd2); 165 166 167 exit(0); 168 }
大家先把這段代碼讀明白,下面我們還要用這段代碼來修改示例。
如果只看上面的代碼是很難理解程序是做什麼的,因為都是一組一組的 case 語句,不容易理解。所以一般使用有限狀態機開發的程序都會與圖或相關的文檔配套發行,看了圖再結合代碼就很容易看出來代碼的目的了。
大家要對比著上面的圖來看代碼,這樣思路就很清晰了。
使用狀態機之前需要使兩個待進行數據中繼的文件描述符必須都是 O_NONBLOCK 的。
整個狀態機中都沒有使用循環來讀寫數據,因為狀態機能確保每一種狀態都是職責單一的,出現其它的任何狀況的時候只要推動狀態機問題就可以解決了。
所以這樣的程序可維護性是不是高了很多?如果出現了需求變更,只需要簡單的修改幾條 case 語句就可以了,而不需要大段大段的修改代碼了。
大家要多使用狀態機的設計思想來寫程序才能加深對這種設計思想的掌握程度。
3. I/O 多路轉接
上面那個 讀tty11 寫tty12,讀tty12 寫tty11 的栗子是采用忙等的方式實現的,I/O 多路轉接這個小節討論的就是怎麼把上面那個栗子修改為非忙等的模式。
有些時候就是這樣的,讀取多個文件(一般是設備)的時候不能使用阻塞方式,因為一個阻塞了其它的就沒法讀了;而非阻塞方式如果采用忙等的形式又得不償失。你想想比如 telnet 服務在接收用戶的命令的時候是不是這種情況呢?
對於處理這樣的需求,Linux 系統為我們提供了 3 種方案:select(2)、poll(2) 和 epoll(7),這些方案提供的函數可以同時監視多個文件描述符,當它們的狀態沒有變化時阻塞等待,當它們的狀態發生變化時會給我們一個通知讓我們繼續處理任務,下面我們一個一個的介紹它們。
先來看第一個函數:select(2)
1 select, FD_CLR, FD_ISSET, FD_SET, FD_ZERO - synchronous I/O multiplexing 2 3 /* According to POSIX.1-2001 */ 4 #include <sys/select.h> 5 6 /* According to earlier standards */ 7 #include <sys/time.h> 8 #include <sys/types.h> 9 #include <unistd.h> 10 11 int select(int nfds, fd_set *readfds, fd_set *writefds, 12 fd_set *exceptfds, struct timeval *timeout); 13 14 void FD_CLR(int fd, fd_set *set); 15 int FD_ISSET(int fd, fd_set *set); 16 void FD_SET(int fd, fd_set *set); 17 void FD_ZERO(fd_set *set);
select(2) 的優點是足夠老,各個平台都支持它,這也是它相對於 poll(2) 唯一的優點。
參數列表:
nfds:最大的文件描述符 + 1;
readfds:需要監視的輸入文件描述符集合;
writefds:需要監視的輸出文件描述符集合;
exceptfds:需要監視的會發生異常的文件描述符集合;
timeout:等待的超時時間,如果時間超時依然沒有文件描述符狀態發生變化那麼就返回。設置為 0 會立即返回,設置為 NULL 則一直阻塞等待,不會超時。
還記得我們之前提到過使用 select(2) 函數替代 sleep(3) 函數嗎?記不起來的童鞋自己回去翻看前面的博文吧,這裡不再贅述了。
我們看到參數中的文件描述符集合是 fd_set 類型的,那麼怎麼把我們的 int 類型的文件描述符添加到 fd_set 當中去呢?
通過帶參數的宏 FD_CLR 就可以將文件描述符 fd 添加到 set 中了。
帶參數的宏 FD_ZERO 的作用是清空 set 中的文件描述符。
帶參數的宏 FD_ISSET 的作用是測試文件描述符 fd 是否在 set 集合中。
下面我們重構上面的栗子,通過把它修改成非忙等的形式來看看 select 是如何使用的。代碼沒有太大的區別,所以只貼出有差異的部分。
1 enum 2 { 3 STATE_R=1, 4 STATE_W, 5 STATE_AUTO, // 添加這個值是為了起到分水嶺的作用,小於這個值的時候才需要使用 select(2) 監視 6 STATE_Ex, 7 STATE_T 8 }; 9 10 static int max(int a,int b) 11 { 12 if(a < b) 13 return b; 14 return a; 15 } 16 17 static void relay(int fd1,int fd2) 18 { 19 int fd1_save,fd2_save; 20 struct fsm_st fsm12,fsm21; 21 fd_set rset,wset; // 讀寫文件描述符集合 22 23 fd1_save = fcntl(fd1,F_GETFL); 24 fcntl(fd1,F_SETFL,fd1_save|O_NONBLOCK); 25 fd2_save = fcntl(fd2,F_GETFL); 26 fcntl(fd2,F_SETFL,fd2_save|O_NONBLOCK); 27 28 fsm12.state = STATE_R; 29 fsm12.sfd = fd1; 30 fsm12.dfd = fd2; 31 32 fsm21.state = STATE_R; 33 fsm21.sfd = fd2; 34 fsm21.dfd = fd1; 35 36 37 while(fsm12.state != STATE_T || fsm21.state != STATE_T) 38 { 39 //布置監視任務 40 FD_ZERO(&rset); 41 FD_ZERO(&wset); 42 43 // 讀態監視輸入文件描述符;寫態監視輸出文件描述符 44 if(fsm12.state == STATE_R) 45 FD_SET(fsm12.sfd,&rset); 46 if(fsm12.state == STATE_W) 47 FD_SET(fsm12.dfd,&wset); 48 if(fsm21.state == STATE_R) 49 FD_SET(fsm21.sfd,&rset); 50 if(fsm21.state == STATE_W) 51 FD_SET(fsm21.dfd,&wset); 52 53 if(fsm12.state < STATE_AUTO || fsm21.state < STATE_AUTO) 54 { 55 // 以阻塞形式監視 56 if(select(max(fd1,fd2)+1,&rset,&wset,NULL,NULL) < 0) 57 { 58 if(errno == EINTR) 59 continue; 60 perror("select()"); 61 exit(1); 62 } 63 } 64 65 //查看監視結果 66 if( FD_ISSET(fd1,&rset) || FD_ISSET(fd2,&wset) || fsm12.state > STATE_AUTO) 67 fsm_driver(&fsm12); 68 if( FD_ISSET(fd2,&rset) || FD_ISSET(fd1,&wset) || fsm21.state > STATE_AUTO) 69 fsm_driver(&fsm21); 70 } 71 72 fcntl(fd1,F_SETFL,fd1_save); 73 fcntl(fd2,F_SETFL,fd2_save); 74 75 }
在上面的栗子中,無論設備中是否有數據供我們讀取我們都不停的推動狀態機,所以導致出現了忙等的現象。
而在這個栗子中,我們在推狀態機之前使用 select(2) 函數對文件描述符進行監視,如果文件描述狀態沒有發生變化就阻塞等待;而哪個狀態機的文件描述符發生了變化就推動哪個狀態機,這樣就將查詢法的實現改為通知法的實現了。是不是很簡單呢?
poll(2) 出現的時間沒有 select(2) 那麼悠久,所以在可移植性上來說沒有 select(2) 函數那麼好,但是絕大多數主流 *nix 平台都支持 poll(2) 函數,它比 select(2) 要優秀很多,下面我們來了解下它。
1 poll - wait for some event on a file descriptor 2 3 #include <poll.h> 4 5 int poll(struct pollfd *fds, nfds_t nfds, int timeout); 6 7 struct pollfd { 8 int fd; /* 需要監視的文件描述符 */ 9 short events; /* 要監視的事件 */ 10 short revents; /* 該文件描述符發生了的事件 */ 11 };
參數列表:
fds:實際上是一個數組的首地址,因為 poll(2) 可以幫助我們監視多個文件描述符,而一個文件描述放到一個 struct pollfd 結構體中,多個文件描述符就需要一個數組來存儲了。
nfds:fds 這個數組的長度。在參數列表中使用數組首地址 + 長度的做法還是比較常見的。
timeout:阻塞等待的超時時間。傳入 -1 則始終阻塞,不超時。
結構體中的事件可以指定下面七種事件,同時監視多個事件可以使用按位或(|)添加:
事件 描述 POLLIN 文件描述符可讀 POLLPRI 可以非阻塞的讀高優先級的數據 POLLOUT 文件描述符可寫 POLLRDHUP 流式套接字連接點關閉,或者關閉寫半連接。 POLLERR 已出錯 POLLHUP 已掛斷(一般指設備) POLLNVAL 參數非法表1 poll(2) 可以監視的 7 種事件
使用 poll(2) 的步驟也很簡單:
1)首先通過 struct pollfd 結構體中的 events 成員布置監視任務;
2)然後使用 poll(2) 函數進行阻塞的監視;
3)當從 poll(2) 函數返回時就可以通過 struct polfd 結構體中的 revents 成員與上面的 7 個宏中被我們選出來監視的宏進行按位與(&)操作了,只要結果不為 1 就認為觸發了該事件。
好了,這 3 步就是 poll(2) 函數的使用方法,簡單吧。
下面我們修改一下上面的栗子,把上面用 select(2) 實現的部分修改為用 poll(2) 來實現。沒有改過的地方就不貼出來了,其實也只有 relay() 函數被修改了。
1 static void relay(int fd1,int fd2) 2 { 3 int fd1_save,fd2_save; 4 struct fsm_st fsm12,fsm21; 5 struct pollfd pfd[2]; // 一共監視兩個文件描述符 6 7 8 fd1_save = fcntl(fd1,F_GETFL); 9 fcntl(fd1,F_SETFL,fd1_save|O_NONBLOCK); 10 fd2_save = fcntl(fd2,F_GETFL); 11 fcntl(fd2,F_SETFL,fd2_save|O_NONBLOCK); 12 13 fsm12.state = STATE_R; 14 fsm12.sfd = fd1; 15 fsm12.dfd = fd2; 16 17 fsm21.state = STATE_R; 18 fsm21.sfd = fd2; 19 fsm21.dfd = fd1; 20 21 pfd[0].fd = fd1; 22 pfd[1].fd = fd2; 23 24 while(fsm12.state != STATE_T || fsm21.state != STATE_T) 25 { 26 // 布置監視任務 27 pfd[0].events = 0; 28 if(fsm12.state == STATE_R) 29 pfd[0].events |= POLLIN; // 第一個文件描述符可讀 30 if(fsm21.state == STATE_W) 31 pfd[0].events |= POLLOUT; // 第一個文件描述符可寫 32 33 pfd[1].events = 0; 34 if(fsm12.state == STATE_W) 35 pfd[1].events |= POLLOUT; // 第二個文件描述符可讀 36 if(fsm21.state == STATE_R) 37 pfd[1].events |= POLLIN; // 第二個文件描述符可寫 38 39 // 只要是可讀寫狀態就進行監視 40 if(fsm12.state < STATE_AUTO || fsm21.state < STATE_AUTO) 41 { 42 // 阻塞監視 43 while(poll(pfd,2,-1) < 0) 44 { 45 if(errno == EINTR) 46 continue; 47 perror("poll()"); 48 exit(1); 49 } 50 } 51 52 // 查看監視結果 53 if( pfd[0].revents & POLLIN || \ 54 pfd[1].revents & POLLOUT || \ 55 fsm12.state > STATE_AUTO) 56 fsm_driver(&fsm12); // 推狀態機 57 if( pfd[1].revents & POLLIN || \ 58 pfd[0].revents & POLLOUT || \ 59 fsm21.state > STATE_AUTO) 60 fsm_driver(&fsm21); // 推狀態機 61 } 62 63 fcntl(fd1,F_SETFL,fd1_save); 64 fcntl(fd2,F_SETFL,fd2_save); 65 66 }
代碼中注釋寫得很明確了,相信不需要 LZ 再解釋什麼了。
epoll(7) 不是一個函數,它在 man 手冊的第 7 章裡,它是 Linux 為我們提供的“加強版 poll(2)”,既然是加強版,那麼一定有超越 poll(2) 的地方,下面就聊一聊 epoll(7)。
在使用 poll(2) 的時候用戶需要管理一個 struct pollfd 結構體或它的結構體數組,epoll(7) 則使內核為我們管理了這個結構體數組,我們只需要通過 epoll_create(2) 返回的標識引用這個結構體即可。
1 epoll_create - open an epoll file descriptor 2 3 #include <sys/epoll.h> 4 5 int epoll_create(int size);
調用 epoll_create(2) 時最初 size 參數給傳入多少,kernel 在建立數組的時候就是多少個元素。但是這種方式不好用,所以後來改進了,只要 size 隨便傳入一個正整數就可以了,內核不會再根據大家傳入的 size 直接作為數組的長度了,因為內核是使用 hash 來管理要監視的文件描述符的。
返回值是 epfd,從這裡也可以體現出 Linux 一切皆文件的設計思想。失敗時返回 -1 並設置 errno。
得到了內核為我們管理的結構體數組標識之後,接下來就可以用 epoll_ctl(2) 函數布置監視任務了。
1 epoll_ctl - control interface for an epoll descriptor 2 3 #include <sys/epoll.h> 4 5 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 6 7 8 struct epoll_event { 9 uint32_t events; /* Epoll 監視的事件,這些事件與 poll(2) 能監視的事件差不多,只是宏名前面加了個E */ 10 epoll_data_t data; /* 用戶數據,除了能保存文件描述符以外,還能讓你保存一些其它有關數據,比如你這個文件描述符是嵌在一棵樹上的,你在使用它的時候不知道它是樹的哪個節點,則可以在布置監視任務的時候將相關的位置都保存下來。這個聯合體成員就是 epoll 設計的精髓。 */ 11 };
epoll_ctl(2) 的作用是要對 fd 增加或減少(op) 什麼行為的監視(event)。成功返回0,失敗返回 -1 並設置 errno。
op 參數可以使用下面三個宏來指定操作:
宏 描述 EPOLL_CTL_ADD 增加要監視的文件描述符 EPOLL_CTL_MOD 更改目標文件描述符的事件 EPOLL_CTL_DEL 刪除要監視的文件描述符,event 參數會被忽略,可以傳入 NULL。表2 epoll_ctl(2) 函數 op 參數的選項
與 select(2) 和 poll(2) 一樣, 布置完監視任務之後需要取監視結果,epoll(7) 策略使用 epoll_wait(2) 函數進行阻塞監視並返回監視結果。
1 epoll_wait - wait for an I/O event on an epoll file descriptor 2 3 #include <sys/epoll.h> 4 5 int epoll_wait(int epfd, struct epoll_event *events, 6 int maxevents, int timeout);
參數列表:
epfd:要操作的 epoll 實例;
events + maxevents:共同指定了一個結構體數組,數組的起始位置和長度。其實每次使用 epoll_ctl(2) 函數添加一個文件描述符時相當於向內核為我們管理的數組中添加了一個成員,所以當我們使用同一個 struct epoll_event 變量操作多個文件描述符時,只需傳入該變量的地址和操作了多少個文件描述符即可,大家看看下面的栗子就明白了。
timeout:超時等待的時間,設置為 -1 則始終阻塞監視,不超時。
跟上面的栗子一樣,LZ 只貼出來被修改了的 relay() 函數,其它部分不變。
1 static void relay(int fd1,int fd2) 2 { 3 int fd1_save,fd2_save; 4 struct fsm_st fsm12,fsm21; 5 int epfd; 6 struct epoll_event ev; 7 8 epfd = epoll_create(10); 9 if(epfd < 0) 10 { 11 perror("epfd()"); 12 exit(1); 13 } 14 15 fd1_save = fcntl(fd1,F_GETFL); 16 fcntl(fd1,F_SETFL,fd1_save|O_NONBLOCK); 17 fd2_save = fcntl(fd2,F_GETFL); 18 fcntl(fd2,F_SETFL,fd2_save|O_NONBLOCK); 19 20 fsm12.state = STATE_R; 21 fsm12.sfd = fd1; 22 fsm12.dfd = fd2; 23 24 fsm21.state = STATE_R; 25 fsm21.sfd = fd2; 26 fsm21.dfd = fd1; 27 28 ev.events = 0; 29 ev.data.fd = fd1; 30 epoll_ctl(epfd,EPOLL_CTL_ADD,fd1,&ev); 31 32 ev.events = 0; 33 ev.data.fd = fd2; 34 epoll_ctl(epfd,EPOLL_CTL_ADD,fd2,&ev); 35 36 37 while(fsm12.state != STATE_T || fsm21.state != STATE_T) 38 { 39 // 布置監視任務 40 41 ev.events = 0; 42 ev.data.fd = fd1; 43 if(fsm12.state == STATE_R) 44 ev.events |= EPOLLIN; 45 if(fsm21.state == STATE_W) 46 ev.events |= EPOLLOUT; 47 epoll_ctl(epfd,EPOLL_CTL_MOD,fd1,&ev); 48 49 ev.events = 0; 50 ev.data.fd = fd2; 51 if(fsm12.state == STATE_W) 52 ev.events |= EPOLLOUT; 53 if(fsm21.state == STATE_R) 54 ev.events |= EPOLLIN; 55 epoll_ctl(epfd,EPOLL_CTL_MOD,fd2,&ev); 56 57 // 監視 58 if(fsm12.state < STATE_AUTO || fsm21.state < STATE_AUTO) 59 { 60 while(epoll_wait(epfd,&ev,1,-1) < 0) 61 { 62 if(errno == EINTR) 63 continue; 64 perror("epoll_wait()"); 65 exit(1); 66 } 67 } 68 69 // 查看監視結果 70 if( ev.data.fd == fd1 && ev.events & EPOLLIN || \ 71 ev.data.fd == fd2 && ev.events & EPOLLOUT || \ 72 fsm12.state > STATE_AUTO) 73 fsm_driver(&fsm12); 74 if( ev.data.fd == fd2 && ev.events & EPOLLIN || \ 75 ev.data.fd == fd1 && ev.events & EPOLLOUT || \ 76 fsm21.state > STATE_AUTO) 77 fsm_driver(&fsm21); 78 } 79 80 fcntl(fd1,F_SETFL,fd1_save); 81 fcntl(fd2,F_SETFL,fd2_save); 82 83 close(epfd); 84 85 }
4.記錄鎖
記錄鎖就是用 fcntl(2) 函數創建一個鎖文件,比較麻煩,感興趣的童鞋可以自己看看書上的介紹,在這裡 LZ 就不做介紹了,我們在最後會討論兩個方便的文件鎖和鎖文件。
5.異步 I/O
這部分主要是說信號驅動 IO,不是真正意義上的異步 IO。
異步 I/O 分為 System V 異步 I/O 和 BSD 異步 I/O,Linux 模仿的是後者,這裡我們不過多討論了,後面 LZ 在討論內核的博文中會繼續討論異步。
6. readv(2) 和 write(2)
1 readv, writev - read or write data into multiple buffers 2 3 #include <sys/uio.h> 4 5 ssize_t readv(int fd, const struct iovec *iov, int iovcnt); 6 7 ssize_t writev(int fd, const struct iovec *iov, int iovcnt); 8 9 struct iovec { 10 void *iov_base; /* 起始地址 */ 11 size_t iov_len; /* Number of bytes to transfer */ 12 };
這兩個函數的作用就是對多個碎片的讀寫操作,將所有的小碎片寫到文件中。
readv(2) 當沒有連續的空間存儲從 fd 讀取或寫入的數據時,將其存儲在 iovcnt 個 iov 結構體中,writev(2) 的作用相同。iov 是結構體數組起始位置,iovcnt 是數組長度。
7. readn() 和 writen()
這兩個函數可以從本書(《APUE》第三版)的光盤中找,它們並不是什麼標准庫的函數,也不是系統調用,只是本書作者自己封裝的函數,算是方言中的方言,作用是堅持寫夠 n 個字節,之前我們在討論 IO 的博文中實現過類似的效果。
對了,天朝在引入這本書的時候貌似沒有引入配套光盤,需要的童鞋可以自己去網上搜索一下。
7.存儲映射 I/O
存儲映射 I/O 是十四章的小重點。
在 *nix 系統中分配內存的方法有好幾種,不一定非得使用 free(3) 函數。
通過 mmap(2) 和 unmap(2) 函數可以實現一個實時的類似於 malloc(3) 和 free(3) 函數的效果,我們在前面的博文中提到過,malloc(3) 和 free(3) 實際上是以打白條的形式實現的,就是在你調用函數的時候並沒有立即分配內存給你,而是在你真正使用內存的時候才分配給你的。
存儲映射I/O說的就是將一個文件的一部分或全部映射到內存中,用戶拿到的就是這段內存的起始位置,訪問這個文件就相當於訪問一個大字符串一樣。
1 mmap, munmap - map or unmap files or devices into memory 2 3 #include <sys/mman.h> 4 5 void *mmap(void *addr, size_t length, int prot, int flags, 6 int fd, off_t offset); 7 int munmap(void *addr, size_t length);
mmap(2) 函數的作用是把 fd 這個文件從 offset 偏移位置開始把 length 字節個長度映射到 addr 這個內存位置上,如果 addr 參數傳入 NULL 則由 kernel 幫我們選擇一塊空間並使用返回值返回這段內存的首地址。
prot 參數是操作權限,可以使用下表中的宏通過按位或(|)來組合指定。
宏 含義 PROT_READ 映射區可讀 PROT_WRITE 映射區可寫 PROT_EXEC 映射區可執行 PROT_NONE 映射區不可訪問表3 mmap(2) 函數的 prot 參數可選項
映射區不可訪問(PROT_NONE)的含義是如果我映射的內存中有一塊已經有某些數據了,絕對不能讓我的程序越界覆蓋了,就可以把這段空間設置為映射區不可訪問。
flags 參數是特殊要求,以下二者必選其一:
宏 含義 MAP_SHARED 對映射區進行存儲操作相當於對原來的文件進行寫入,會改變原來文件的內容。 MAP_PRIVATE 當對映射區域進行存儲操作時會創建一個私有副本,所有後來再對映射區的操作都相當於操作這個副本,而不影響原來的文件。表4 mmap(2) 函數的 flags 參數可選項
其它常用選項:
MAP_ANONYMOUS:不依賴於任何文件,映射出來的內存空間會被清 0,並且 fd 和 offset 參數會被忽略,通常我們在使用的時候會把 fd 設置為 -1。
用這個參數可以很容易的做出一個最簡單最好用的在具有親緣關系的進程之間的共享內存,比後面第15章我們要討論的共享內存還好用。後面 LZ 會給出一個小栗子讓大家看看這種方式如何使用。
mmap(2) 在成功的時候返回一個指針,會指向映射的內存區域的起始地址。失敗時返回 MAP_FAILED 宏定義,其實是這樣定義的:(void *) -1。
首先我們寫一個栗子看看如何把一個文件映射到內存中訪問。
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <sys/mman.h> 4 #include <sys/types.h> 5 #include <sys/stat.h> 6 #include <unistd.h> 7 #include <fcntl.h> 8 9 10 #define FNAME "/etc/services" 11 12 int main(void) 13 { 14 int fd,i; 15 char *str; 16 struct stat statres; 17 int count = 0; 18 19 fd = open(FNAME,O_RDONLY); 20 if(fd < 0) 21 { 22 perror("open()"); 23 exit(1); 24 } 25 26 // 通過 stat(2) 獲得文件大小 27 if(fstat(fd,&statres) < 0) 28 { 29 perror("fstat()"); 30 exit(1); 31 } 32 33 str = mmap(NULL,statres.st_size,PROT_READ,MAP_SHARED,fd,0); 34 if(str == MAP_FAILED) 35 { 36 perror("mmap()"); 37 exit(1); 38 } 39 40 // 將文件映射到內存之後文件描述符就可以關閉了,直接訪問映射的內存就相當於訪問文件了。 41 close(fd); 42 43 for(i = 0 ; i < statres.st_size; i++) { 44 // 因為訪問的是文本文件,所以可以把映射的內存看作是一個大字符串處理 45 if(str[i] == 'a') { 46 count++; 47 } 48 } 49 50 printf("count = %d\n",count); 51 52 // 用完了別忘了解除映射,不然會造成內存洩漏! 53 munmap(str,statres.st_size); 54 55 exit(0); 56 }
這段代碼會統計 /etc/services 文件中包含多少個字符 'a'。
mmap(2) 的返回值是 void* 類型的,這是一種百搭的類型,在映射了不同的東西的情況下我們可以使用不同的指針來接收,這樣就能用不同的方式訪問這段內存空間了。上面這個文件是文本文件,所以我們可以使用 char* 來接收它的返回值,這樣就將整個文件看作是一個大字符串來訪問了。
這個還是比較常規的用法,下面我們看一下如何使用 mmap(2) 函數制作一個好用的共享內存。
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <sys/mman.h> 4 #include <sys/types.h> 5 #include <sys/stat.h> 6 #include <unistd.h> 7 #include <fcntl.h> 8 #include <string.h> 9 #include <wait.h> 10 11 #define MEMSIZE 1024 12 13 int main(void) 14 { 15 char *str; 16 pid_t pid; 17 18 // 這裡在 flags 中添加 MAP_ANONYMOUS,為制作共享內存做准備 19 str = mmap(NULL,MEMSIZE,PROT_READ|PROT_WRITE,MAP_SHARED|MAP_ANONYMOUS,-1,0); 20 if(str == MAP_FAILED) 21 { 22 perror("mmap()"); 23 exit(1); 24 } 25 26 // 創建子進程,父子進程使用共享內存進行通信 27 pid = fork(); 28 if(pid < 0) 29 { 30 perror("fork()"); 31 exit(1); 32 } 33 if(pid == 0) // 子進程向共享內存中寫入數據 34 { 35 strcpy(str,"Hello!"); 36 munmap(str,MEMSIZE); // 注意,雖然共享內存是在 fork(2) 之前創建的,但是 fork(2) 的時候子進程也拷貝了一份,所以子進程使用完畢之後也要解除映射 37 exit(0); 38 } 39 else // 父進程從共享內存中讀取子進程寫入的數據 40 { 41 wait(NULL); // 保障子進程先運行起來,因為就算父進程先運行了也會在這裡阻塞等待 42 puts(str); // 把從共享內存中讀取出來的數據打印出來 43 munmap(str,MEMSIZE); // 不要忘記解除映射 44 exit(0); 45 } 46 47 48 exit(0); 49 }
共享內存是進程間通信的一種手段,就是在內存中開辟一塊空間讓多個進程之間可以共同訪問這段空間,從而實現進程之間的數據交換。在後面討論 IPC 的博文中我們還會詳細介紹共享內存,不過用 mmap(2) 制作的共享內存比後面介紹的共享內存使用起來更簡便一些。
大家自己運行一下這段代碼,可以看到父進程打印出了子進程寫入的“Hello”字符串,說明這段內存確實是在父子進程之間共享的。
大家在使用的時候不要忘記父子進程最後都要做解除映射的動作。
從這個栗子中我們也可以看出來,這種共享內存的方式只適合在具有親緣關系的進程之間使用,沒有親緣關系的進程是無法獲得指向同一個映射內存空間的指針的。
8. flock(2) 和 lockf(3) 函數
1 lockf - apply, test or remove a POSIX lock on an open file 2 3 #include <unistd.h> 4 5 int lockf(int fd, int cmd, off_t len); 6 7 flock - apply or remove an advisory lock on an open file 8 9 #include <sys/file.h> 10 11 int flock(int fd, int operation);
這兩個函數可以實現好用的文件加鎖。
我們這裡只介紹 lockf(2) 函數,flock(2) 函數也差不多,都很簡單,所以大家可以自己去查閱 man 手冊。
lockf(3) 可以給文件進行局部加鎖,簡單來說就是從當前位置鎖住 len 個字節。
參數列表:
fd:要加鎖的文件描述符;
cmd:具體的命令見下表;
宏 說明 F_LOCK為文件的一段加鎖,如果已經被加鎖就阻塞等待,如果兩個鎖要鎖定的部分有交集就會被合並
,文件關閉時或進程退出時會自動釋放,不會被子進程繼承。
F_TLOCK 與 F_LOCK 差不多,不過是嘗試加鎖,非阻塞。 F_ULOCK 解鎖,如果是被合並的鎖會分裂。 F_TEST測試鎖,如果文件中被測試的部分沒有鎖定或者是調用進程持有鎖就返回 0;
如果是其它進程持有鎖就返回 -1,並且 errno 設置為 EAGAIN 或 EACCES。
圖5 lockf(3) 函數的 cmd 參數可選值
len:要鎖定的長度,如果為 0 表示文件有多長鎖多長,從當前位置一直鎖到文件結尾。
下面我們使用 lockf(3) 函數寫一個栗子。
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <string.h> 4 #include <unistd.h> 5 #include <wait.h> 6 #include <sys/types.h> 7 8 9 #define PROCNUM 20 10 #define FNAME "/tmp/out" 11 #define BUFSIZE 1024 12 13 14 static void func_add() 15 { 16 FILE *fp; 17 int fd; 18 char buf[BUFSIZE]; 19 20 fp = fopen(FNAME,"r+"); 21 if(fp == NULL) 22 { 23 perror("fopen()"); 24 exit(1); 25 } 26 27 fd = fileno(fp); 28 if(fd < 0) 29 { 30 perror("fd"); 31 exit(1); 32 } 33 34 // 使用之前先鎖定 35 lockf(fd,F_LOCK,0); 36 37 fgets(buf,BUFSIZE,fp); 38 rewind(fp); // 把文件位置指針定位到文件首 39 sleep(1); // 放大競爭 40 fprintf(fp,"%d\n",atoi(buf)+1); 41 fflush(fp); 42 43 // 使用之後釋放鎖 44 lockf(fd,F_ULOCK,0); 45 46 fclose(fp); 47 48 return ; 49 } 50 51 int main(void) 52 { 53 int i; 54 pid_t pid; 55 56 for(i = 0 ; i < PROCNUM ; i++) 57 { 58 pid = fork(); 59 if(pid < 0) 60 { 61 perror("fork()"); 62 exit(1); 63 } 64 if(pid == 0) // child 65 { 66 func_add(); 67 exit(0); 68 } 69 } 70 71 for(i = 0 ; i < PROCNUM ; i++) 72 wait(NULL); 73 74 75 exit(0); 76 }
還是用我麼以前的栗子改的,大家還記得以前寫過一個栗子,讓 20 個進程同時向 1 個文件中累加數字嗎。
在這裡每個進程在讀寫文件之前先加鎖,如果加不上就等待別人釋放鎖再加。如果加上了鎖就讀出文件中當前的值,+1 之後再寫回到文件中。
獲得鎖之後 sleep(1) 是為了放大競爭,讓進程之間一定要出現競爭的現象,便於我們分析調試。
在調試並發的程序時,如果有些問題很難復現,那麼可以通過加長每一個並發單位的執行時間來強制它們出現競爭的情況,這樣可以讓我們更容易的分析問題。
圖3 flock(2) 和 lockf(3) 的缺點
文件鎖還有一個機制是把一個文件當作鎖,比如要操作的是 /tmp/out 文件,那麼父進程可以先創建一個 /tmp/lcok文件,然後再創建 20 個子進程同時對 /tmp/out 文件進行讀寫,但是子進程必須先鎖定 /tmp/lock 文件才能操作 /tmp/out 文件,沒搶到鎖文件的需要等待其它進程解鎖再搶鎖,等父進程為所有的子進程收屍之後再關閉/tmp/lock,/tmp/lock 這個文件就被稱為鎖文件。
高級 IO 部分大概就這些內容了,有什麼疑問歡迎大家在評論中討論。