Linux高性能服務器開發

公眾號:暢游碼海 更多高質量原創文章都在里面~

主機字節序和網絡字節序:

在32位機器上,累加器一次能裝載4個字節,這四個字節在內存中排列順序將影響它被累加器裝載成的整數的值

大端字節序(網絡字節序):一個整數的高位字節存儲在內存的低地址處

小端字節序(現代PC大多數采用):整數的高位字節存儲在內存的高地址處

即使是同一臺機器上不同語言編寫的程序通信,也要考慮字節序的問題

Linux下字節序轉換函數:

 #include<netinet/in.h>
 unsigned long int htol (unsigned long int hostlong); //主機字節序轉換成網絡字節序
 unsigned short int htons (unsigned short int hostshort);//主機字節序轉換成網絡字節序
 unsigned long int ntohl (unsigned long int netlong);//網絡字節序轉換成主機字節序
 unsigned short int ntohs (unsigned short int netshort);//網絡字節序轉換成主機字節序

socket地址

 #include<bits/sockets.h>
 struct sockaddr{
   sa_family_t sa_family;  //地址族類型的變量與協議族對應 
   char sa_data[14];   //存放socket地址值
 }
協議族 地址族 描述 地址值含義和長度
PF_UNIX AF_UNIX UNIX本地域協議族 文件的路徑名,長度可達108字節
PF_INET AF_INET TCP/IPv4協議族 16bit端口號和32bit IPv4地址,6字節
PF_INET6 AF_INET6 TCP/IPv6協議族 16bit端口號,32bit流標識,128bit IPv6地址,32bit范圍ID,共26字節

為了容納多數協議族地址值,Linux重新定義了socket地址結構體

#include<bits/socket.h>
struct sockaddr_storage{
    sa_family_t sa_family;
    unsigned long int __ss_align; //是內存對齊的
    char __ss_padding[128-sizeof(__ss_align)];
}

Linux為TCP/IP協議族有sockaddr_in和sockaddr_in6兩個專用socket地址結構體,它們分別用于IPv4和IPv6

 //對于IPv4的:
 struct sockaddr_in{
   sa_family sin_family;   //地址族:AF_INET
   u_int16_t sin_port;     //端口號,要用網絡字節序表示
   struct in_addr sin_addr;//IPv4地址結構體
 }
 //IPv4的結構體
 struct in_addr
 {
   u_int32_t s_addr;   //要用網絡字節序表示
 }
 //對于IPv6
 struct sockaddr_in6{
   sa_family_t sin6_family;//AF_INET6
   u_int16_t sin6_port;    //端口號,要用網絡字節序表示
   u_int32_t sin6_flowinfo;//流信息,應設置為0
   struct in6_addr sin6_addr;//IPv6地址結構體
   u_int32_t sin6_scope_id;//scope ID,處于試驗階段
 }
 //IPv6的結構體
 struct in6_addr
 {
   unsigned char sa_addr[16];  //要用網絡字節序表示
 }

使用的時候要強制轉換成通用的socket地址類型socketaddr

點分十進制字符串表示的IPv4地址和網絡字節序整數表示的IPv4地址轉換

 #incldue<arpa/inet.h>
 in_addr_t inet_addr(const char* strptr);          //點分十進制--->網絡字節序整數 ,失敗返回INADDR_NONE
 int inet_aton (const char* cp,struct in_addr* inp);//功能同上,結果存儲于參數inp指向的地址結構中,成功返回1,失敗返回0
 char* inet_ntoa (struct in_addr in);  //網絡字節序整數--->點分十進制,函數內部用靜態變量存儲轉化結果,返回值指向該變量,inet_ntoa是不可重入的
//功能同上,可用于IPv6
#include<arpa/inet.h>
int inet_pton(int af,const char* src,void* dst);//把結果存放在dst所指內存中,其中af代表協議族----成功返回1,失敗返回0并且設置error
const char* inet_ntop(int af,const void* src,char* dst,socklen_t cnt);//同理


//下面兩個宏可幫助我們指定cnt的大小
#include<netinet/in.h>
#define INET_ADDRSTRLEN 16
#define INET6_ADDRSTRLEN 46

創建socket

Linux上所有東西都是文件

 #include<sys/types.h>
 #include<sys/socket.h>
 int socket (int domain,int type ,int protocol);//domain參數代表底層協議族(IPv4使用PF_INET)、Type參數指定服務類型分為SOCK_STREAM服務(流服務器--使用TCP協議)和SOCK_DGRAM服務(數據報服務--使用UDP協議)、protocol參數是在前兩個參數構成的協議集合下,再選擇一個具體的協議(幾乎所有情況下它設置0,表示使用默認協議)

socket系統調用成功時返回一個socket文件描述符,失敗則返回-1并設置errno

命名socket

創建了socket,并且指定了地址族,但是并沒有指定使用地址族中具體socket地址

將一個socket與socket地址綁定稱為給socket命名

客戶端通常不需要命名socket,而是采用匿名方式,即使用操作系統自動分配的socket地址

 #include<sys/types.h>
 #include<sys/socket.h>
 int bind (int sockfd,const struct sockaddr* my_addr,socklen_t addrlen)//bind將my_addr所指的socket地址分配給未命名的sockfd文件描述符,addrlen參數指出該socket地址的長度,bind成功返回0,失敗返回-1并設置errno
 ```

**兩種常見的errno是EACCES和EADDRINUSE**

  **EACCCES:被綁定的地址是受保護的地址,僅超級用戶能訪問。**
  **EADDRINUSE: 被綁定的地址正在使用中(例如將socket綁定到一個處于TIME_WAIT狀態的socket地址)**

## 監聽socket

命名后,還不能馬上接受客戶連接,我們需要使用如下系統調用來創建一個監聽隊列以存放待處理的客戶連接

```c++
#include<sys/socket.h>
int listen (int sockfd,int backlog);//sockfd參數指定被監聽的socket,backlog參數提示內核監聽隊列的最大長度,監聽隊列的長度如果超過backlog,服務器將不再受理新的客戶連接,客戶端也將收到ECONNREFUSED錯誤信息

內核版本2.2之前 :backlog參數是指多有處于半連接的狀態(SYN_RCVD)和完全連接狀態(ESTABLISHED)的socket的上限

內核版本2.2之后:它只表示處于完全連接狀態的socket的上線,處于半連接狀態的socket的上限,則是在tcp_max_syn_backlog內核參數定義。

backlog參數的典型值是5,listen成功時返回0,失敗則返回-1并設置errno

接受連接

 #include<sys/types.h>
 #include<sys/socket.h>
 int accept(int sockfd , struct sockaddr *addr,socklen_t *addrlen);//sockfd參數是執行過listen系統調用的監聽socket。addr參數用來獲取被接受連接的遠端socket地址,該socket地址的長度由addrlen參數指出。accept成功時返回一個新的連接socket,該socket唯一標識了被接受的這個連接,服務器可通過讀寫該socket來與被接受連接對應的客戶端通信。失敗時返回-1,并設置了errno。

發起連接

 #include<sys/types.h>
 #include<sys/socket.h>
 int connect(int sockfd, const struct sockaddr *serv_adr,socklen_t addrlen);//sockfd參數是socket系統調用返回一個socket,serv_addr參數是服務器監聽的socket地址,addrlen參數則是指定

connect成功時返回0,一旦成功建立連接,sockfd就唯一的標識了這個連接,客戶端就可以通過讀寫sockfd來與服務器通信。失敗返回-1并設置errno

ECONNREFUSED: 目標端口不存在,連接被拒絕
ETIMEDOUT: 連接超時

關閉連接

 #include<unistd.h>
 int close(int fd);    //fd參數是待關閉的socket,不過并不是立即關閉連接,而是將fd的引用計數減一,當為0時,才真正關閉連接

多進程程序中,一次系統調用將默認使父進程中打開的socket的引用計數加1,因此我們必須在父進程和子進程中都對該socket執行close調用才能將連接關閉

如果無論如何都要立即終止連接,可以使用shutdown系統調用

 #include<sys/socket.h>
 int shutdown (int sockfd,int howto);//sockfd參數是待關閉的socket,howto參數決定了shutdown的行為
可選值 含義
SHUT_RD 關閉sockfd上讀的這一半。應用程序不再針對socket文件描述符執行讀操作,并且該socket接收緩沖區中的數據都被丟棄
SHUT_WR 關閉sockfd上寫的這一半。sockfd的發送緩沖區中的數據會真正關閉連接之前全部發送出去,應用程序不可再對該sockfd文件描述符執行寫操作。這種情況下,連接處于半連接狀態
SHUT_RDWR 同時關閉sockfd上讀和寫

shutdown能夠分別關閉sockfd上的讀和寫,或者都關閉。而close在關閉連接時只能將sockfd上的讀和寫同時關閉

shutdown成功時返回0,失敗則返回-1并設置errno

數據讀寫

tcp 數據讀寫

 #include<sys/types.h>
 #include<sys/socket.h>
 ssize_t recv (int sockfd , void *buf ,size_t len ,int flags); //recv讀取sockfd上的數據,buf和len參數分別指定讀緩沖區的位置和大小,flags參數的含義見后文,通常設置為0即可。 成功返回實際讀取到的數據的長度,它可能小于我們期望的長度len。因此我們可能要多次調用。 返回0,這意味著通信對方已經關閉連接了,出錯時返回-1,并設置errno。
 ssize_t send (int sockfd , const void *buf ,size_t len,int flags);//send往sockfd上寫入數據,buf和len依然是緩存區的位置和大小。send成功時返回實際寫入的長度,失敗則返回-1,并設置errno。

flags參數提供額外的控制

flags參數值

UDP數據讀寫

 #include<sys/types.h>
 #include<sys/socket.h>
 ssize_t recvfrom (int sockfd ,void* buf , size_t len, int flags , struct sockaddr* src_addr ,socklen_t* addrlen);//recvfrom讀取sockfd上的數據,buf和len參數分別指定讀緩沖區的位置和大小,因為UDP通信沒有連接的概念,所以我們每次讀取數據都需要獲取發送端的socket地址,即參數src_addr所指的內容,addrlen參數則指定該地址的長度
 ssize_t sendto (int sockfd , const void* buf ,size_t len,int flags ,const struct sockaddr* dest_addr, socklen_t addrlen );// sendto往sockfd上寫入數據,buf和len參數分別指定寫緩沖區的位置和大小。dest_addr參數指定接收端的socket地址,addrlen參數則指定該地址的額長度
 //flag含義同上

這兩個也可用于面向連接的socket的數據讀寫,只需要把最后兩個參數都設置為NULL以忽略發送端/接收端的socket地址(已經建立連接了,就知道socket地址了)

通用數據讀寫的函數

#include<sys/socket.h>
ssize_t recvmsg (int sockfd, struct msghdr* msg ,int flags);
ssize_t sendmsg (int sockfd ,struct msghdr* msg,int flags);
//msghdr結構體
struct msghdr
{
    void* msg_name; //socket地址   對于TCP連接這個沒有,因為地址已經知道了
    socklen_t msg_namelen;//socket地址的長度
    struct iovec* msg_lov;//分散的內存塊  //封裝了位置和大小  //數組
    int msg_iovlen;//分散的內存塊數量
    void* msg_control;//指向輔助數據的起始位置
    socllen_t msg_controllen;//輔助數據的大小
    int msg_flags;//賦值函數中的flags參數,并在調用過程中更新
}
struct iovec{
    void *iov_base; //內存起始地址
    size_t iov_len;  //內存塊的長度
}

對于recvmsg來說,數據將被讀取并存放在msg_iovlen塊分散的內存中,這些內存的位置和長度則由msg_iov指向的數組指定,這稱為分散讀;對于sendmsg而言,msg_iovlen塊分散內存中的數據將被一并發送,這稱為集中寫

帶外標記

 #include<sys/socket.h>
 int sockatmark (int sockfd);//判斷sockfd是否處于帶外標記,即下一個被讀取的的數據是否是帶外數據。是則返回1,此時可利用帶MSG_OOB標志的recv調用來接收帶外數據,不是則返回0

地址信息函數

 #include<iosstream>
 int getsockname (int sockfd,struct sockaddr* address, socklen_t* address_len);//獲取本端sockfd地址,并存儲于address參數指定的內存中,長度存儲在address_len參數指定的變量中,實際長度大于address所指內存區的大小,那么該socket地址將被截斷。成功返回0,失敗返回-1,并設置errno
 int getpeername (int sockfd, struct sockaddr* address , socklen_t* address_len);//獲取sockfd對應的遠端socket地址

socket選項

 #include<sys/socket.h>
 int getsockopt (int sockfd,int level,int option_name , void* option_value);//sockfd參數指定被操作的目標socket。level參數指定要操作哪個協議的選項,option_name參數則指定選項的名字 ,option_value和option_len參數分別是被操作選項的值和長度
 int setsockopt (int sockfd , int level ,int option_name ,const void* option_value,socklen_t option_len);

兩個函數成功返回0 ,失敗返回-1并設置errno

socket選項

網絡信息API

 //根據主機名,獲取主機的完整信息
 #include<neidb.h>
 struct hostent* gethostbyname (const char* name);
 struct hostent* gethostbyaddr (const void* addr ,size_t len, int type);
 
 #include<netdb.h>
 struct hostent{
     char* h_name; //主機名
     char** h_aliases;//主機別名列表,可能由多個
     int h_addrtype;   //地址類型(地址族)
     int h_length; //地址長度
     char** h_addr_list;//按網絡字節序列出的主機IP地址列表
 }
  
 ```c++
  //根據名稱獲取某個服務器的完整信息
  #include<netdb.h>
  struct servent* getservbyname (const char* name,const char* proto);
  struct servent* getservbyport (int port ,const char* proto);
  
  #include<netdb.h>
  struct servent{
      char* s_name;//服務名稱
      char** s_aliases;//服務的別名列表,可能多個
      int s_port;//端口號
      char* s_proto;//服務類型,通常是TCP或者UDP
  }
 //通過主機名獲取IP地址,也能通過服務名獲得端口號----內部使用的是geihostbyname和getservbyname
 #include<netdb.h>
 int getaddrinfo (const char* hostname ,const char* service ,const struct addrinfo* hints ,struct addrinfo** result);
 
 struct addrinfo
 {
     int ai_flags;
     int ai_family;    //地址族
     int ai_socktype;//服務類型,SOCK_STREAM或SOCK_DGRAM
     int ai_protocol;
     socklen_t ai_addrlen;//socket地址ai_addr的長度
     char* ai_canonname;//主機的別名
     struct sockaddr* ai_addr;//指向socket地址
     struct addrinfo* ai_next;//指向下一個sockinfo結構的對象
 }

該函數將隱式的分配堆內存,所以我們需要配對下面的函數

 //用來釋放內存
 #include<netdb.h>
 void freeaddrinfo (struct addrinfo* res);
 //將返回的主機名存儲在hsot參數指向的緩存中,將服務名存儲在serv參數指向的緩存中,hostlen和servlen參數分別指定這兩塊緩存的長度
 #include<netdb.h>
 int getnameinfo (const struct sockaddr* sockaddr,socklen_t addrlen,char* host,socklen_t hostlen,char* serv,socklen_t servlen,int flags);

六、高級I/O函數

 //pipe函數可用于創建一個管道,以實現進程間通信
 #include<unistd.h>
 int pipe( int fd[2]);//參數是一個包含兩個int型整數的數組指針,函數成功時返回0,并將打開的文件描述符值填入其參數指向的數組,失敗則返回-1并設置errno
 //fd[0]只能從管道讀出數據,fd[1]則只能用于往管道里寫入數據,而不能反過來使用,要實現雙向,就得使用兩個管道---都是阻塞的
 //方便創建雙向管道
 #include<sys/types>
 #include<sys/socket.h>
 int socketpair (int domain ,int type ,int protocol ,int fd[2]);
 //dpmain只能使用AF_UNIX,僅能在本地使用。最后一個參數則和pipe系統調用的參數一樣,只不過socketpair創建的這對文件描述符都是即可讀有可寫的,成功返回0,失敗返回-1并設置errno
 //把標準輸入重定向到文件或網絡
 #include<unistd.h>
 int dup (int file_descriptor);
 int dup2 (int file_descriptor_one, int file_descriptor_two);
 //分散讀和集中寫
 #include<sys/uio.h>
 ssize_t readv (int fd, const struct iovec* vector ,int count);
 ssize_t writev (int fd , const struct iovec* vector, int count);
 //vector中存儲的是iovec結構數組,count是vector數組的長度
 //在兩個文件描述符之間傳遞數據(完全在內核中操作),從而避免了內核緩沖區和用戶緩沖區之間的數據拷貝,效率很高,這被稱為--------零拷貝
 #include<sys/sendfile.h>
 ssize_t sendfile (int out_fd,int in_fd, off_t* offest ,size_t count);
 //in_fd參數是待讀出內容的文件描述符,out_fd是待寫入內容的文件描述符,offest參數指定從讀入文件流哪個位置開始讀,為空,則使用讀入文件流默認的起始位置,count參數指定在文件描述符之間傳輸的字節數
 //用于申請一段內存空間
 #include<sys/mman.h>
 void* mmap (void *start ,size_t length,int prot ,int flags ,int fd,off_t offest);
 int munmap (void *start,size_t length);
 //start允許用戶使用特定的地址作為起始地址,length指定內存段的長度,port參數用來設置內存段的訪問權限
 //PROT_READ   內存段可讀
 //PROT_WRITE 內存段可寫
 //PROT_EXEC    內存段可執行
 //PROT_NONE    內存段不能被訪問
 //用來在兩個文件描述符之間移動數據----零拷貝
 #include<fcntl.h>
 ssize_t splice (int fd_in ,loff_t* off_in ,int fd_out , loff_t* off_out,size_t len, unsigned int flags);
 //fd_int 如果是管道文件描述符,則off_in設置NULL。如果不是,則off_in參數表示從輸入數據流的何處開始讀取數據,不為NULL則表示具體的偏移位置,fd_out和off_out同理,len參數指定移動數據的長度
 //在兩個管道文件描述符之間復制數據,也就是零拷貝操作
 #include<fcntl.h>
 ssize_t tee (int fd_in ,int fd_out ,size_t len ,unsigned int flags);
 //參數與splice相同
 //提供了對文件描述符的各種控制操作
 #include<fcntl.h>
 int fcntl (int fd,int cmd,···);
 //fd參數是被操作的文件描述符,cmd參數指定執行何種操作,根據類型不同,可能還需要第三個可選參數arg

七、Linux服務器程序規范

服務器程序規范

Linux服務器程序一般以后臺方式運行------守護進程
Linux服務器程序通常有一套日志系統,至少能輸出日志到文件,有的高級服務器還能輸出日志到專門的UDP服務器,大部分后臺進程都在 /var/log目錄下用喲喲自己的日志目錄
Linux服務器程序一般以某個專門的非root身份運行
Linux服務器程序通常是可配置的,服務器通常能處理很多命令行選項,如果一次運行的選項太多,則可以用配置文件來管理,絕大多數服務器程序都是有配置文件的,并存放在/etc目錄下
Linux服務器程序進程通常會在啟動的時候生成一個PID文件并存入/var/run目錄中記錄該后臺進程的PID
Linux服務器程序通常需要考慮系統資源和限制,以預測自身能承受多大負荷

日志

 #include<syslog.h>
 void syslog (int priority ,const char* message , ...)
 //priority參數是所謂的設施值與日志級別的按位或,默認值是LOG_USER
 
 //日志級別
 #include<syslog.h>
 #define LOG_EMERG    0//系統不可用
 #define LOG_ALERT    1//報警,需要理解立即動作 
 #define LOG_CRIT     2//非常嚴重的情況
 #define LOG_ERR      3//錯誤
 #define LOG_WARNING  4//警告
 #define LOG_NOTICE   5//通知
 #define LOG_INFO     6//信息
 #define LOG_DEBUG    7//調試
     
 //改變syslog的默認輸出方式,進一步結構化日志內容    
 #include<syslog.h>
 void openlog (const char* ident ,int logopt ,int facility)    ;
 //ident參數指定的字符串被添加到日志消息的日期和時間之后,通常被設置為程序的名字
 
 //logopt參數對后續syslog調用行為配置
 #define LOG_PID     0x01    //在日志消息中包含程序PID
 #define LOG_CONS    0x02    //如果消息不能記錄到日志文件,則打印至終端
 #define LOG_ODELAY  0x04    //延遲打開日志功能知道第一次調用syslog
 #define LOG_NDELAY  0x08    //不延遲打開日志功能
 
 //設置syslog的日志掩碼
 #include<syslog.h>
 int setlogmask (int maskpri);
 //maskpri參數指定日志掩碼值。該函數始終會成功,它返回調用進程先前的日志掩碼值
 
 //關閉日志功能
 #include<syslog.h>
 void closelog();
 ```

### 用戶信息

```c++
//用來獲取和設置當前進程的真實用戶ID(UID)、有效用戶ID(EUID )、真實組ID(GID)和有效組ID(EGID)
#include<sys/types.h>
#include<unistd.h>
uid_t getuid();       //獲取真實用戶ID
uid_t geteuid();  //獲取有效用戶ID
gid_t getgid();       //獲取真實組ID
gid_t getegid();  //獲取有效組ID
int setuid(uid_t uid);//設置真實用戶ID
int seteuid(uid_t uid);//設置有效用戶ID
int setgid(gid_t gid);//設置真實組ID
int setegid (gid_t gid);//設置有效組ID

一個進程擁有兩個用戶ID:UID和EUID,EUID存在的目的是方便資源訪問:它使得運行程序的用戶擁有該程序的有效用戶的權限

進程間關系

進程組

 #include<unistd.h>
 pid_t getgid (pid_t pid);
 //成功返回進程pid所屬的進程組的PGID,失敗返回-1并設置errno
 ```

**每個進程都有一個首領進程,其PGID和PID相同。進程將一直存在,直到其他所有進程都退出,或者加入到其他進程組**

#### 會話

```c++
 //創建一個會話
 #include<unistd.h>
 pid_t setsid (void);
 //  1.調用進程成為會話的首領,此時該進程是新會話的唯一成員
 //  2.新建一個進程組,其PGID就是調用進程的PID,調用進程成為該組的首領
 //  3.調用進程將甩開終端(如果有的話)
 //讀取SID
 #include<unistd.h>
 pid_t getsid (pid_t pid);
 ```

#### 進程間關系

![進程間關系](https://i.loli.net/2021/11/21/Lpkyoi73lWF1Obd.png)

#### 系統資源限制

```c++
//Linux上運行的程序都會受到資源限制的影響
#include<sys/resource.h>
int getrlimit (int resource , struct rlimit* rlim);       //讀取資源
int setrlimit (int resource , const struct rlimit* rlim);//設置資源

//rlimit結構體
struct rlimit
{
   rlim_t rlim_cur;//指定資源的軟限制
   rlim_t rlim_max;//指定資源的硬限制
}
//rlim_t 是一個整數類型

改變工作目錄和根目錄

 #include<unistd.h>
 char* getcwd (char* buf,size_t size); //獲取當前工作目錄
 int chdir (const char* path);//切換path指定的目錄

 //改變進程根目錄函數
 #include<unistd.h>
 int chroot (const char* path);

八、高性能服務器程序框架

I/O處理單元---四種I/O模型和兩種高效事件處理模式

服務器模型

C/S模型

由于客戶連接請求是隨機到達的異步事件,因此服務器需要使用某種I/O模型來監聽這一事件

 **當監聽到連接請求后,服務器就調用accept函數接受它,并分配一個邏輯單元為新的連接服務。**
 **邏輯單元可以是新創建的子進程,子線程或者其他**
 **服務器給客戶端分配的邏輯單元是由fork系統調用創建的子進程。**
 **邏輯單元讀取客戶請求,處理該請求,然后將處理結果返回給客戶端。**
 **客戶端接收到服務器反饋的結果之后,可以繼續向服務器發送請求,也可以立即主動關閉連接**
 **如果客戶端主動關閉連接,則服務器執行被動關閉連接**

服務器同時監聽多個客戶請求是通過select系統調用實現的

C/S模型非常適合資源相對集中的場合,并且它實現也很簡單,但其缺點也很明顯,服務器是中心,訪問量過大時,可能所有客戶都會得到很慢的響應。

P2P模型

優點:資源能夠充分、自由地共享

缺點:當用戶之間傳輸的請求過多時,網絡負載將加重

主機之前很難互相發現,所以實際使用的P2P模型通常帶有一個專門的發現服務器

服務器編程框架

模塊 單個服務器程序 服務器機群
I/O處理單元 處理客戶連接,讀寫網絡數據 作為接入服務器,實現負載均衡
邏輯單元 業務進程或線程 邏輯服務器
網絡存儲單元 本地數據庫,文件或緩存 數據庫服務器
請求隊列 各單元之間的通信方式 各服務器之間的永久TCP連接

I/O處理單元模塊:等待并接受新的客戶連接,接收客戶數據,將服務器響應數據返回給客戶端

邏輯單元通常是一個進程或線程:它分析并處理客戶數據,然后將結果傳遞給I/O處理單元或者直接發送給客戶端

網絡存儲單元:可以說數據庫,緩存和文件,甚至是一臺獨立的服務器

請求隊列:是各個單元之間的通信方式和抽象I/O處理單元接收到客戶請求時,需要以某種方式通知一個邏輯單元來處理請求,多個邏輯單元同時訪問一個存儲單元時,也需要某種機制來協調處理競態條件。請求隊列通常被實現為池的一部分。對服務器來說,請求隊列是各臺服務器之間預先建立的,靜態的、永久的TCP連接

I/O模型

I/O模型 讀寫操作和阻塞階段
阻塞I/O 程序阻塞于讀寫函數
I/O復用 程序阻塞于I/O復用系統調用,但可同時監聽 多個I/O事件,對I/O本身的讀寫操作是非阻塞的
SIGIO信號 信號觸發讀寫就緒事件,用戶程序執行讀寫操作。程序沒有阻塞階段
異步I/O 內核執行讀寫操作并觸發讀寫完成事件,程序沒有阻塞階段

阻塞式IO

  • 使用系統調用,并一直阻塞直到內核將數據準備好,之后再由內核緩沖區復制到用戶態,在等待內核準備的這段時間什么也干不了
  • 下圖函數調用期間,一直被阻塞,直到數據準備好且從內核復制到用戶程序才返回,這種IO模型為阻塞式IO
  • 阻塞式IO式最流行的IO模型

優缺點

優點:開發簡單,容易入門;在阻塞等待期間,用戶線程掛起,在掛起期間不會占用CPU資源。

缺點:一個線程維護一個IO,不適合大并發,在并發量大的時候需要創建大量的線程來維護網絡連接,內存、線程開銷非常大。

非阻塞式IO

  • 內核在沒有準備好數據的時候會返回錯誤碼,而調用程序不會休眠,而是不斷輪詢詢問內核數據是否準備好
  • 下圖函數調用時,如果數據沒有準備好,不像阻塞式IO那樣一直被阻塞,而是返回一個錯誤碼。數據準備好時,函數成功返回。
  • 應用程序對這樣一個非阻塞描述符循環調用成為輪詢。
  • 非阻塞式IO的輪詢會耗費大量cpu,通常在專門提供某一功能的系統中才會使用。通過為套接字的描述符屬性設置非阻塞式,可使用該功能

優缺點

同步非阻塞IO優點:每次發起IO調用,在內核等待數據的過程中可以立即返回,用戶線程不會阻塞,實時性較好。

同步非阻塞IO缺點:多個線程不斷輪詢內核是否有數據,占用大量CPU時間,效率不高。一般Web服務器不會采用此模式。

多路復用IO

  • 類似與非阻塞,只不過輪詢不是由用戶線程去執行,而是由內核去輪詢,內核監聽程序監聽到數據準備好后,調用內核函數復制數據到用戶態
  • 下圖中select這個系統調用,充當代理類的角色,不斷輪詢注冊到它這里的所有需要IO的文件描述符,有結果時,把結果告訴被代理的recvfrom函數,它本尊再親自出馬去拿數據
  • IO多路復用至少有兩次系統調用,如果只有一個代理對象,性能上是不如前面的IO模型的,但是由于它可以同時監聽很多套接字,所以性能比前兩者高
  • 多路復用包括

    • select:線性掃描所有監聽的文件描述符,不管他們是不是活躍的。有最大數量限制(32位系統1024,64位系統2048)
    • poll:同select,不過數據結構不同,需要分配一個pollfd結構數組,維護在內核中。它沒有大小限制,不過需要很多復制操作
    • epoll:用于代替poll和select,沒有大小限制。使用一個文件描述符管理多個文件描述符,使用紅黑樹存儲。同時用事件驅動代替了輪詢。epoll_ctl中注冊的文件描述符在事件觸發的時候會通過回調機制激活該文件描述符。epoll_wait便會收到通知。最后,epoll還采用了mmap虛擬內存映射技術減少用戶態和內核態數據傳輸的開銷

優缺點

IO多路復用優點:系統不必創建維護大量線程,只使用一個線程,一個選擇器即可同時處理成千上萬個連接,大大減少了系統開銷。

IO多路復用缺點:本質上,select/epoll系統調用是阻塞式的,屬于同步IO,需要在讀寫事件就緒后,由系統調用進行阻塞的讀寫。

信號驅動式IO

  • 使用信號,內核在數據準備就緒時通過信號來進行通知
  • 首先開啟信號驅動io套接字,并使用sigaction系統調用來安裝信號處理程序,內核直接返回,不會阻塞用戶態
  • 數據準備好時,內核會發送SIGIO信號,收到信號后開始進行io操作

異步IO

  • 異步IO依賴信號處理程序來進行通知
  • 不過異步IO與前面IO模型不同的是:前面的都是數據準備階段的阻塞與非阻塞,異步IO模型通知的是IO操作已經完成,而不是數據準備完成
  • 異步IO才是真正的非阻塞,主進程只負責做自己的事情,等IO操作完成(數據成功從內核緩存區復制到應用程序緩沖區)時通過回調函數對數據進行處理
  • unix中異步io函數以aio_或lio_打頭

異步IO優點:真正實現了異步非阻塞,吞吐量在這幾種模式中是最高的。

異步IO缺點:應用程序只需要進行事件的注冊與接收,其余工作都交給了操作系統內核,所以需要內核提供支持。在Linux系統中,異步IO在其2.6才引入,目前也還不是灰常完善,其底層實現仍使用epoll,與IO多路復用相同,因此在性能上沒有明顯占優

五種IO模型對比

  • 前面四種IO模型的主要區別在第一階段,他們第二階段是一樣的:數據從內核緩沖區復制到調用者緩沖區期間都被阻塞住!
  • 前面四種IO都是同步IO:IO操作導致請求進程阻塞,直到IO操作完成
  • 異步IO:IO操作不導致請求進程阻塞

以上I/O模型詳解部分來源于網絡

兩種高效的事件處理模式

兩種事件處理模式ReactorProactor分別對應同步I/O模型、異步I/O模型

Reactor模式

它要求主線程(I/O處理單元)只負責監聽文件描述上是否有事件發生,有的話就立即將該事件通知工作線程(邏輯單元)。除此之外,主線程不做任何其他實質性的工作。-----讀寫數據,接受新的連接,以及處理客戶請求均在工作線程完成
1. 主線程epoll內核事件表中注冊socket上的讀就緒事件
2. 主線程調用epoll_wait等待socket上有數據可讀
3. 當socket上有數據可讀時,epoll_wait通知主線程。主線程則將socket可讀事件放入請求隊列
4. 睡眠在請求隊列上的某個工作線程被喚醒,它從socket讀取數據,并處理客戶端請求,然后往epoll內核事件表中注冊該socket上的寫就緒事件
5. 主線程調用epoll_wait等待socket可寫
6. 當socket可寫時,epoll_wait通知主線程。主線程將socket可寫事件放入請求隊列
7. 睡眠在請求隊列上的某個工作線程被喚醒,它 往socket上寫入服務器處理客戶請求的結果

Proactor模式

Proactor模式將所有I/O操作都交給主線程和內核來處理,工作線程僅僅負責業務邏輯
1. 主線程調用aio_read函數向內核注冊socket上的讀完成事件,并告訴內核用戶讀緩沖區的位置,以及讀操作完成時如何通知應用程序
2. 主線程繼續處理其他邏輯
3. 當socket上的數據被讀入用戶緩沖區后,內核將向應用程序發送一個信號,以通知應用程序數據已經可用
4. 應用程序預先定義好的信號處理函數選擇一個工作線程來處理客戶請求。工作線程處理完客戶請求之后,調用aio_write函數向內核注冊socket上的寫完成事件,并告訴內核用戶寫緩沖區的位置,以及寫操作完成時如何通知應用程序
5. 主線程繼續處理其他邏輯
6. 當用戶緩沖區的數據被寫入socket之后,內核將向應用程序發送一個信號,以通知應用程序數據以及發送完畢
7. 應用程序預先定義好的信號處理函數選擇一個工作線程來做善后處理,比如決定是否關閉socket

同步I/O模型模擬出Proactor

主線程執行數據讀寫操作,讀完成之后,主線程向工作線程通知這一“完成事件”。那么從工作線程的角度來看,它們就直接獲得了數據讀寫的結果,接下來要做的只是對讀寫的操作進行邏輯處理
1. 主線程往epoll內核事件表中注冊socket上的讀就緒事件
2. 主線程調用epoll_wait等待socket上有數據可讀
3. 當socket上有數據可讀時,epoll_wait通知主線程。主線程從socket循環讀取數據,直到沒有更多數據可讀,然后將數據封裝成一個請求對象并插入請求隊列
4. 睡眠在請求隊列上的某個工作線程被喚醒,它獲得請求對象并處理客戶請求,然后往epoll內核事件表中注冊socket上的寫就緒事件
5. 主線程調用epoll_wait等待socket可寫
6. 當socket可寫時,epoll_wait通知主線程。主線程往socket上寫入服務器處理客戶請求的結果

兩種高效的并發模型

并發模型是指I/O處理單元和多個邏輯單元之間協調完成任務的方法。兩種并發編程模式-------半同步/半異步模式、領導者/追隨者模式

半同步/半異步模式

同步和異步和前面I/O模型中的同步和異步完全不同。

 **在I/O模型中,“同步”和“異步”區分的是內核向應用程序通知的是何種I/O事件(是就緒事件還是完成事件),以及該由誰來完成I/O讀寫(應用程序還是內核)**
 **在并發模式中,“同步”指的是程序完成按照代碼序列的順序執行:“異步”指的是程序的執行需要由系統事件來驅動** 

半同步/半異步工作流程

半同步/半異步模式變體------半同步/半異步反應堆

![半同步_半異步反應堆模式](https://i.loli.net/2021/11/21/fNIhLvi9QgYPr1k.png)



 **異步線程只有一個,由主線程來充當,它負責監聽所有socket上的事件。如果監聽socket上有可讀事件發生------有新的連接請求到來,主線程就接受之以得到新的連接socket,然后往epoll內核事件表中注冊該socket上的讀寫事件。如果連接socket上有讀寫事件發生----有新的客戶請求到來或有數據要發送至客戶端,主線就將該連接socket插入請求隊列中。所有工作線程都睡眠在請求隊列上,當有任務到來時,它們將通過競爭(比如申請互斥鎖)獲得任務的接管權。這種競爭機制使得只有空閑的工作線程才有機會來處理新任務,這是很合理的**

缺點:

 **主線程和工作線程共享請求隊列。主線程往請求隊列中添加任務,或者工作線程從請求隊列中去除任務,都需要對請求隊列加鎖保護,從而白白耗費CPU時間。**
 **每個工作線程都在同一時間只能處理一個客戶請求。如果客戶數量較多,而工作線程較少,則請求隊列中將堆積很多任務對象,客戶端的響應速度將越來越慢。如果通過增加工作線程來解決這一問題,則工作線程的切換也將耗費大量CPU時間**

變體----相對高效的

主線程只管理監聽socket,連接socket由工作線程來管理。當有新的連接到來時,主線程就接受并將新返回的連接socket派發給某個工作線程,此后該新socket上的任何I/O操作都由被選中的工作線程來處理,直到客戶關閉連接。主線程向工作線程派發socket的最簡單的方式,是往它和工作線程之間的管道里寫數據。工作線程檢測到管道上有數據可讀時,就分析是否是一個新的客戶連接請求到來。如果是,則把該新socket上的讀寫事件注冊到自己的epool內核事件表中

領導者/追隨者模式

 **領導者/追隨者模式是多個工作線程輪流獲得事件源集合、輪流監聽、分發并處理事件的一種模式。在任意時間點,程序僅有一個領導者線程,它負責監聽I/O事件。而其他線程則都是追隨者,它們休眠在線程池中等待成為新的領導者。當前的領導者如果檢測到I/O事件,首先要從線程池中推選出新的領導者線程,然后處理I/O事件。此時,新的領導者等待新的I/O事件,而原來的領導者則處理I/O事件,二者實現并發**

 包含:

   **句柄集、線程集、事件處理器和具體的事件處理器**

 ![領導者追隨者模式組件](https://i.loli.net/2021/11/21/aGkA7obLFqreN1T.png)

 **使用wait_for_event方法來監聽這些句柄上的I/O事件,并將其中的就緒事件通知給領導者線程**

 線程集中的線程在**任一時間**必處于以下**三種狀態之一:**

   **Leader:線程當前處于領導者身份,負責等待句柄集上的I/O事件**
   **Processing:線程正在處理事件。領導者檢測到I/O事件之后,可以轉移到processing狀態來處理該事件,并調用promote_new_leader方法推選出新的領導者:也可以指定其他追隨者來處理事件,此時領導者的地位不變。當處于processing狀態的線程處理完事件之后,如果當前線程集中沒有領導者,則它將成為新的領導者,否則它就直接轉變為追隨者**
   **Follower:線程當前處于追隨者身份,通過調用線程集dejoin方法等待成為新的領導者,也可能被當前的領導者指定來處理新的任務**

 ![領導者追隨者狀態轉移](https://i.loli.net/2021/11/21/fCvItE314wT62qU.png)

 **事件處理器和具體的事件處理器**

 ![領導者追隨者工作流程](https://i.loli.net/2021/11/21/vzopABXsTq8xLnl.png)

  > 上圖為工作流程

在邏輯單元內部的一種高效編程方法--------有限狀態機

其他提高服務器性能的手段

內存池、進程池、線程池和連接池
避免不必要的拷貝,如使用共享內存零拷貝
盡量避免上下文的切換(線程切換)和鎖的使用,因為都會增加開銷

多進程編程

fork系統調用

用來Linux下創建新進程的系統

    #include<sys/types.h>
    #include<unistd.h>
    pid_t fork(void);
    //該函數的每次調用都返回兩次,在父進程中返回的是子進程的PID,在子進程中則返回0.該返回值是后續代碼判斷當前進程是父進程還是子進程的依據。fork調用失敗時返回-1,并設置errno。

fork函數復制當前進程,在內核進程表中創建一個新的進程表項。新的進程表項有很多屬性和原進程相同,比如堆指針、棧指針和標志寄存器的值。但也有許多屬性被賦予了新的值,比如該進程的PPID被設置成原進程的PID,信號位圖被清楚(原進程設置的信號處理函數不再對新進程起作用)

  • 子進程的代碼與父進程完全相同,同時它還會復制父進程的數據(堆數據、棧數據和靜態數據)。數據的復制采用的是所謂的寫時復制,即只有在任一進程(父進程或子進程)對數據執行了寫操作時,復制才會發生(顯示缺頁中斷,然后操作系統給子進程分配內存并復制父進程的數據)。即便如此,如果我們在程序中分配了大量內存,那么使用fork時也應該十分謹慎,避免沒必要的內存分配和數據復制。創建進程后,父進程中打開的文件描述符默認在子進程中也是打開的,且文件描述符的引用計數加1.父進程的用戶根目錄,當前工作目錄等變量的引用計數均會加1。

exec系列系統調用

    #include<unistd.h>
    extern char** environ;
    
    int execl(const char* path,const char* argv,...);
    int execlp(const char* file,const char* arg, ...);
    int execle(const char* path,const char* arg, ... ,char* const envp[]);
    int execv(const char* path,char* const argv[]);
    int execvp(const char* file,char* const argv[]);
    int execve(const char* path,char* const argv[],char* const envp[]);
    //path參數指定可執行文件的完整路徑,file參數可以接受文件名,該文件的具體位置則在環境變量PATH中搜尋。arg接受可變參數,argv則接受參數數組,它們都會被傳遞給新程序(path或file指定的程序)的main函數,envp參數用于設置新程序的環境變量。如果未設置它,則新程序將使用由全局變量environ指定的環境變量
    //出錯時返回-1,并設置errno。如果沒出錯,則源程序中exec調用之后的代碼都不會執行,因為此時源程序已經被exec的參數指定的程序完全替換(包括代碼和數據)

exec函數不會關閉原程序打開的文件描述符,除非該文件描述符被設置了類似SOCK_CLOEXEC的屬性

處理僵尸進程

對于多進程程序而言,父進程一般需要跟蹤子進程的退出狀態。因此,當子進程結束運行時,內核不會立即釋放該進程的進程表表項,以滿足父進程后續對該子進程退出信息的查詢(如果父進程還在運行)。子進程結束運行之后,父進程讀取其退出狀態之前,我們稱該子進程繼續運行。此時子進程的PPID將被操作系統設置為1,即init進程。init進程接管了子進程,并等待它結束。父進程退出之后,子進程退出之前,該子進程處于僵尸態。

    //僵尸態會占據內核資源,因此使用下列函數來等待子進程的結束,并獲取子進程的返回信息,從而避免了僵尸進程的產生,或者使子進程呢個的僵尸態立即結束
    #include<sys/types.h>
    #incldue<sys.wait.h>
    pid_t wait(int* stat_loc);
    //wait函數將阻塞進程,直到該進程的某個子進程結束運行為止,它返回結束運行的子進程的PID,并將該子進程的退出狀態信息存儲于stat_loc參數指向的內存中。sys/wait.h頭文件中定義了幾個宏來幫助解釋子進程的退出狀態信息
    pid_t waitpid(pid_t pid,int* stat_loc,int options);
    //waitpid函數只等待由pid參數指定的子進程。如果pid取值為-1,那么它就和wait函數相同,即等待任意一個子進程結束。stat_loc參數的含義和wait函數的stat_loc參數相同,options參數可以控制waitpid函數的行為
    //WNOHANG  waitpid調用將是非阻塞的,目標進程未結束立即返回0,如果正常退出則返回PID,失敗返回-1,并設置errno

常在SIGCHLD信號中調用waitpid,并在循環中徹底結束一個子進程

管道

管道是父進程和子進程通信的常用手段。
管道能在父、子進程間傳遞數據,利用的是fork調用之后兩個管道文件描述符(fd[0]和fd[1])都保持打開。一堆這樣的文件描述符只能保證父子進程間一個方向的數據傳輸,復制進程必須有一個關閉fd[0],另一個關閉fd[1]----因此必須使用兩個管道。
socket編程提供了一個雙全工管道的系統調用socketpair。---------只能用于有關聯的兩個進程(如父子進程)

System IPC

這三種用來無關聯的多個進程之間通信的方式信號、共享內存、消息隊列

信號量
 **當多個進程訪問系統上的某個資源的時候,就需要考慮進程的同步問題,以確保任意時刻只有一個進程可以擁有對資源的獨占式訪問----我們稱對共享資源的訪問的代碼為關鍵代碼即臨界區。**

公眾號:暢游碼海 這里有我更多的原創文章,歡迎關注,支持原創!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,501評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,673評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,610評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,939評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,668評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,004評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,001評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,173評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,705評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,426評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,656評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,139評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,833評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,247評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,580評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,371評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,621評論 2 380

推薦閱讀更多精彩內容