Linux 高效能伺服器程式設計學習

語言: CN / TW / HK

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

  • 主機位元組序和網路位元組序:

  • 在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地址

    • EACCCES:被繫結的地址是受保護的地址,僅超級使用者能訪問。

    • EADDRINUSE: 被繫結的地址正在使用中(例如將socket繫結到一個處於TIME_WAIT狀態的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

  • 監聽socket

  • 命名後,還不能馬上接受客戶連線,我們需要使用如下系統呼叫來建立一個監聽佇列以存放待處理的客戶連線

  • #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);
  • getnameinfo的flags

  • getaddrinfo錯誤碼
  • 六、高階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 記憶體段不能被訪問
  • mmap的flags
  • //用來在兩個檔案描述符之間移動資料----零拷貝
    #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引數指定移動資料的長度

splice的flags
  • //在兩個管道檔案描述符之間複製資料,也就是零拷貝操作
    #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
  • fcntl支援的操作1
fcntl支援的操作2
  • 七、Linux伺服器程式規範

  • 伺服器程式規範:

    • Linux伺服器程式一般以後臺方式執行------守護程序

    • Linux伺服器程式通常有一套日誌系統,至少能輸出日誌到檔案,有的高階伺服器還能輸出日誌到專門的UDP伺服器,大部分後臺程序都在 /var/log目錄下用擁有自己的日誌目錄

    • Linux伺服器程式一般以某個專門的非root身份執行

    • Linux伺服器程式通常是可配置的,伺服器通常能處理很多命令列選項,如果一次執行的選項太多,則可以用配置檔案來管理,絕大多數伺服器程式都是有配置檔案的,並存放在/etc目錄下

    • Linux伺服器程式程序通常會在啟動的時候生成一個PID檔案並存入/var/run目錄中記錄該後臺程序的PID

    • Linux伺服器程式通常需要考慮系統資源和限制,以預測自身能承受多大負荷

  • 日誌

  • 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();
  • 使用者資訊

  • //用來獲取和設定當前程序的真實使用者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相同。程序將一直存在,直到其他所有程序都退出,或者加入到其他程序組

  • 會話

    • //建立一個會話
      #include<unistd.h>
      pid_t setsid (void);
      // 1.呼叫程序成為會話的首領,此時該程序是新會話的唯一成員
      // 2.新建一個程序組,其PGID就是呼叫程序的PID,呼叫程序成為該組的首領
      // 3.呼叫程序將甩開終端(如果有的話)
      //讀取SID
      #include<unistd.h>
      pid_t getsid (pid_t pid);
  • 程序間關係

  • 程序間關係
  • 系統資源限制

  • //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模型和兩種高效事件處理模式

  • 伺服器模型

    • 當監聽到連線請求後,伺服器就呼叫accept函式接受它,並分配一個邏輯單元為新的連線服務。

    • 邏輯單元可以是新建立的子程序,子執行緒或者其他

    • 伺服器給客戶端分配的邏輯單元是由fork系統呼叫建立的子程序。

    • 邏輯單元讀取客戶請求,處理該請求,然後將處理結果返回給客戶端。

    • 客戶端接收到伺服器反饋的結果之後,可以繼續向伺服器傳送請求,也可以立即主動關閉連線

    • 如果客戶端主動關閉連線,則伺服器執行被動關閉連線

    • C/S模型

    • C_S模型
    • 由於客戶連線請求是隨機到達的非同步事件,因此伺服器需要使用某種I/O模型來監聽這一事件

    • 伺服器同時監聽多個客戶請求是通過select系統呼叫實現的

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

    • P2P模型

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

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

    • 主機之前很難互相發現,所以實際使用的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模型

程序阻塞於recvfrom

同步阻塞

優缺點

優點:開發簡單,容易入門;在阻塞等待期間,使用者執行緒掛起,在掛起期間不會佔用CPU資源。

缺點:一個執行緒維護一個IO,不適合大併發,在併發量大的時候需要建立大量的執行緒來維護網路連線,記憶體、執行緒開銷非常大。

非阻塞式IO

  • 核心在沒有準備好資料的時候會返回錯誤碼,而呼叫程式不會休眠,而是不斷輪詢詢問核心資料是否準備好

  • 下圖函式呼叫時,如果資料沒有準備好,不像阻塞式IO那樣一直被阻塞,而是返回一個錯誤碼。資料準備好時,函式成功返回。

  • 應用程式對這樣一個非阻塞描述符迴圈呼叫成為輪詢。

  • 非阻塞式IO的輪詢會耗費大量cpu,通常在專門提供某一功能的系統中才會使用。通過為套接字的描述符屬性設定非阻塞式,可使用該功能

recvfrom呼叫

優缺點

同步非阻塞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處理單元)只負責監聽檔案描述上是否有事件發生,有的話就立即將該事件通知工作執行緒(邏輯單元)。除此之外,主執行緒不做任何其他實質性的工作。-----讀寫資料,接受新的連線,以及處理客戶請求均在工作執行緒完成

    • reactor
  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操作都交給主執行緒和核心來處理,工作執行緒僅僅負責業務邏輯

    • proactor
    1. 主執行緒呼叫aio_read函式向核心註冊socket上的讀完成事件,並告訴核心使用者讀緩衝區的位置,以及讀操作完成時如何通知應用程式

    2. 主執行緒繼續處理其他邏輯

    3. 當socket上的資料被讀入使用者緩衝區後,核心將嚮應用程式傳送一個訊號,以通知應用程式資料已經可用

    4. 應用程式預先定義好的訊號處理函式選擇一個工作執行緒來處理客戶請求。工作執行緒處理完客戶請求之後,呼叫aio_write函式向核心註冊socket上的寫完成事件,並告訴核心使用者寫緩衝區的位置,以及寫操作完成時如何通知應用程式

    5. 主執行緒繼續處理其他邏輯

    6. 當用戶緩衝區的資料被寫入socket之後,核心將嚮應用程式傳送一個訊號,以通知應用程式資料以及傳送完畢

    7. 應用程式預先定義好的訊號處理函式選擇一個工作執行緒來做善後處理,比如決定是否關閉socket

  • 同步I/O模型模擬出Proactor

    • 主執行緒執行資料讀寫操作,讀完成之後,主執行緒向工作執行緒通知這一“完成事件”。那麼從工作執行緒的角度來看,它們就直接獲得了資料讀寫的結果,接下來要做的只是對讀寫的操作進行邏輯處理

    • 同步模擬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事件,而原來的領導者則處理I/O事件,二者實現併發

    • 包含:

    • 領導者追隨者模式元件
    • 使用wait_for_event方法來監聽這些控制代碼上的I/O事件,並將其中的就緒事件通知給領導者執行緒

    • 執行緒集中的執行緒在 任一時間 必處於以下 三種狀態之一:

    • 領導者追隨者狀態轉移
    • 事件處理器和具體的事件處理器

    • 領導者追隨者工作流程

      上圖為工作流程

    • 控制代碼集、執行緒集、事件處理器和具體的事件處理器

    • Leader:執行緒當前處於領導者身份,負責等待控制代碼集上的I/O事件

    • Processing:執行緒正在處理事件。領導者檢測到I/O事件之後,可以轉移到processing狀態來處理該事件,並呼叫promote_new_leader方法推選出新的領導者:也可以指定其他追隨者來處理事件,此時領導者的地位不變。當處於processing狀態的執行緒處理完事件之後,如果當前執行緒集中沒有領導者,則它將成為新的領導者,否則它就直接轉變為追隨者

    • Follower:執行緒當前處於追隨者身份,通過呼叫執行緒集dejoin方法等待成為新的領導者,也可能被當前的領導者指定來處理新的任務

    • 主執行緒和工作執行緒共享請求佇列。主執行緒往請求佇列中新增任務,或者工作執行緒從請求佇列中去除任務,都需要對請求佇列加鎖保護,從而白白耗費CPU時間。

    • 每個工作執行緒都在同一時間只能處理一個客戶請求。如果客戶數量較多,而工作執行緒較少,則請求佇列中將堆積很多工物件,客戶端的響應速度將越來越慢。如果通過增加工作執行緒來解決這一問題,則工作執行緒的切換也將耗費大量CPU時間

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

    • 在I/O模型中,“同步”和“非同步”區分的是核心嚮應用程式通知的是何種I/O事件(是就緒事件還是完成事件),以及該由誰來完成I/O讀寫(應用程式還是核心)

    • 在併發模式中,“同步”指的是程式完成按照程式碼序列的順序執行:“非同步”指的是程式的執行需要由系統事件來驅動

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

    • 併發中的同步和非同步
    • 半同步/半非同步工作流程

    • 半同步_半非同步工作流程
    • 半同步/半非同步模式變體------半同步/半非同步反應堆

    • 半同步_半非同步反應堆模式

    • 缺點:

    • 主執行緒和工作執行緒共享請求佇列。主執行緒往請求佇列中新增任務,或者工作執行緒從請求佇列中去除任務,都需要對請求佇列加鎖保護,從而白白耗費CPU時間。

    • 每個工作執行緒都在同一時間只能處理一個客戶請求。如果客戶數量較多,而工作執行緒較少,則請求佇列中將堆積很多工物件,客戶端的響應速度將越來越慢。如果通過增加工作執行緒來解決這一問題,則工作執行緒的切換也將耗費大量CPU時間

    • 變體----相對高效的

    • 高效半同步_半非同步模式
    • 主執行緒只管理監聽socket,連線socket由工作執行緒來管理。當有新的連線到來時,主執行緒就接受並將新返回的連線socket派發給某個工作執行緒,此後該新socket上的任何I/O操作都由被選中的工作執行緒來處理,直到客戶關閉連線。主執行緒向工作執行緒派發socket的最簡單的方式,是往它和工作執行緒之間的管道里寫資料。工作執行緒檢測到管道上有資料可讀時,就分析是否是一個新的客戶連線請求到來。如果是,則把該新socket上的讀寫事件註冊到自己的epool核心事件表中

    • 領導者/追隨者模式

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

  • 其他提高伺服器效能的手段

    • 記憶體池、程序池、執行緒池和連線池

    • 避免不必要的拷貝,如使用 共享記憶體零拷貝

    • 儘量避免上下文的切換(執行緒切換)和鎖的使用,因為都會增加開銷

  • 多程序程式設計

  • 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

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

    • 這三種用來無關聯的多個程序之間通訊的方式: 訊號、共享記憶體、訊息佇列

    • 訊號量

    公眾號裡有我更多的原創文章,歡迎關注,支援原創!

    寫留言