在 TCP/IP 協議中,"IP地址 + TCP或UDP端口號" 能夠惟一標識網絡通信中的一個進程,"IP地址+端口號" 就稱爲 socket。本文以一個簡單的 TCP 協議爲例,介紹如何建立基於 TCP 協議的網絡程序。html
下圖描述了 TCP 協議的通信流程(此圖來自互聯網):linux
下圖則描述 TCP 創建鏈接的過程(此圖來自互聯網):編程
服務器調用 socket()、bind()、listen() 函數完成初始化後,調用 accept() 阻塞等待,處於監聽端口的狀態,客戶端調用 socket() 初始化後,調用 connect() 發出 SYN 段並阻塞等待服務器應答,服務器應答一個SYN-ACK 段,客戶端收到後從 connect() 返回,同時應答一個 ACK 段,服務器收到後從 accept() 返回。服務器
TCP 鏈接創建後數據傳輸的過程:網絡
創建鏈接後,TCP 協議提供全雙工的通訊服務,可是通常的客戶端/服務器程序的流程是由客戶端主動發起請求,服務器被動處理請求,一問一答的方式。所以,服務器從 accept() 返回後馬上調用 read(),讀 socket 就像讀管道同樣,若是沒有數據到達就阻塞等待,這時客戶端調用 write() 發送請求給服務器,服務器收到後從 read() 返回,對客戶端的請求進行處理,在此期間客戶端調用 read() 阻塞等待服務器的應答,服務器調用 write() 將處理結果發回給客戶端,再次調用 read() 阻塞等待下一條請求,客戶端收到後從 read() 返回,發送下一條請求,如此循環下去。socket
下圖描述了關閉 TCP 鏈接的過程:函數
若是客戶端沒有更多的請求了,就調用 close() 關閉鏈接,就像寫端關閉的管道同樣,服務器的 read() 返回 0,這樣服務器就知道客戶端關閉了鏈接,也調用 close() 關閉鏈接。注意,任何一方調用 close() 後,鏈接的兩個傳輸方向都關閉,不能再發送數據了。若是一方調用 shutdown() 則鏈接處於半關閉狀態,仍可接收對方發來的數據。性能
在學習 socket 編程時要注意應用程序和 TCP 協議層是如何交互的: 學習
下面經過一個簡單的 TCP 網絡程序來理解相關概念。程序分爲服務器端和客戶端兩部分,它們之間經過 socket 進行通訊。spa
下面是一個很是簡單的服務器端程序,它從客戶端讀字符,而後將每一個字符轉換爲大寫並回送給客戶端:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <netinet/in.h> #define MAXLINE 80 #define SERV_PORT 8000 int main(void) { struct sockaddr_in servaddr, cliaddr; socklen_t cliaddr_len; int listenfd, connfd; char buf[MAXLINE]; char str[INET_ADDRSTRLEN]; int i, n; // socket() 打開一個網絡通信端口,若是成功的話, // 就像 open() 同樣返回一個文件描述符, // 應用程序能夠像讀寫文件同樣用 read/write 在網絡上收發數據。 listenfd = socket(AF_INET, SOCK_STREAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(SERV_PORT); // bind() 的做用是將參數 listenfd 和 servaddr 綁定在一塊兒, // 使 listenfd 這個用於網絡通信的文件描述符監聽 servaddr 所描述的地址和端口號。 bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); // listen() 聲明 listenfd 處於監聽狀態, // 而且最多容許有 20 個客戶端處於鏈接待狀態,若是接收到更多的鏈接請求就忽略。 listen(listenfd, 20); printf("Accepting connections ...\n"); while (1) { cliaddr_len = sizeof(cliaddr); // 典型的服務器程序能夠同時服務於多個客戶端, // 當有客戶端發起鏈接時,服務器調用的 accept() 返回並接受這個鏈接, // 若是有大量的客戶端發起鏈接而服務器來不及處理,還沒有 accept 的客戶端就處於鏈接等待狀態。 connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len); n = read(connfd, buf, MAXLINE); printf("received from %s at PORT %d\n", inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)), ntohs(cliaddr.sin_port)); for (i = 0; i < n; i++) { buf[i] = toupper(buf[i]); } write(connfd, buf, n); close(connfd); } }
把上面的代碼保存到文件 server.c 文件中,並執行下面的命令編譯:
$ gcc server.c -o server
而後運行編譯出來的 server 程序:
$ ./server
此時咱們能夠經過 ss 命令來查看主機上的端口監聽狀況:
如上圖所示,server 程序已經開始監聽主機的 8000 端口了。
下面讓咱們介紹一下這段程序中用到的 socket 相關的 API。
int socket(int family, int type, int protocol);
socket() 打開一個網絡通信端口,若是成功的話,就像 open() 同樣返回一個文件描述符,應用程序能夠像讀寫文件同樣用 read/write 在網絡上收發數據。對於IPv4,family 參數指定爲 AF_INET。對於 TCP 協議,type 參數指定爲 SOCK_STREAM,表示面向流的傳輸協議。若是是 UDP 協議,則 type 參數指定爲 SOCK_DGRAM,表示面向數據報的傳輸協議。protocol 指定爲 0 便可。
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
服務器須要調用 bind 函數綁定一個固定的網絡地址和端口號。bind() 的做用是將參數 sockfd 和 myaddr 綁定在一塊兒,使 sockfd 這個用於網絡通信的文件描述符監聽 myaddr 所描述的地址和端口號。struct sockaddr *是一個通用指針類型,myaddr 參數實際上能夠接受多種協議的 sockaddr 結構體,而它們的長度各不相同,因此須要第三個參數 addrlen 指定結構體的長度。
程序中對 myaddr 參數的初始化爲:
bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(SERV_PORT);
首先將整個結構體清零,而後設置地址類型爲 AF_INET,網絡地址爲 INADDR_ANY,這個宏表示本地的任意 IP 地址,由於服務器可能有多個網卡,每一個網卡也可能綁定多個 IP 地址,這樣設置能夠在全部的 IP 地址上監聽,直到與某個客戶端創建了鏈接時才肯定下來到底用哪一個 IP 地址,端口號爲 SERV_PORT,咱們定義爲 8000。
int listen(int sockfd, int backlog);
listen() 聲明 sockfd 處於監聽狀態,而且最多容許有 backlog 個客戶端處於鏈接待狀態,若是接收到更多的鏈接請求就忽略。
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
三方握手完成後,服務器調用 accept() 接受鏈接,若是服務器調用 accept() 時尚未客戶端的鏈接請求,就阻塞等待直到有客戶端鏈接上來。cliaddr 是一個傳出參數,accept() 返回時傳出客戶端的地址和端口號。addrlen 參數是一個傳入傳出參數(value-result argument),傳入的是調用者提供的緩衝區 cliaddr 的長度以免緩衝區溢出問題,傳出的是客戶端地址結構體的實際長度(有可能沒有佔滿調用者提供的緩衝區)。若是給 cliaddr 參數傳 NULL,表示不關心客戶端的地址。
服務器程序的主要結構以下:
while (1) { cliaddr_len = sizeof(cliaddr); connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len); n = read(connfd, buf, MAXLINE); ...... close(connfd); }
整個是一個 while 死循環,每次循環處理一個客戶端鏈接。因爲 cliaddr_len 是傳入傳出參數,每次調用 accept( ) 以前應該從新賦初值。accept() 的參數 listenfd 是先前的監聽文件描述符,而 accept() 的返回值是另一個文件描述符 connfd,以後與客戶端之間就經過這個 connfd 通信,最後關閉 connfd 斷開鏈接,而不關閉 listenfd,再次回到循環開頭 listenfd 仍然用做 accept 的參數。
下面是客戶端程序,它從命令行參數中得到一個字符串發給服務器,而後接收服務器返回的字符串並打印:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <netinet/in.h> #include <arpa/inet.h> #define MAXLINE 80 #define SERV_PORT 8000 int main(int argc, char *argv[]) { struct sockaddr_in servaddr; char buf[MAXLINE]; int sockfd, n; char *str; if (argc != 2) { fputs("usage: ./client message\n", stderr); exit(1); } str = argv[1]; sockfd = socket(AF_INET, SOCK_STREAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr); servaddr.sin_port = htons(SERV_PORT); // 因爲客戶端不須要固定的端口號,所以沒必要調用 bind(),客戶端的端口號由內核自動分配。 // 注意,客戶端不是不容許調用 bind(),只是沒有必要調用 bind() 固定一個端口號, // 服務器也不是必須調用 bind(),但若是服務器不調用 bind(),內核會自動給服務器分配監聽端口, // 每次啓動服務器時端口號都不同,客戶端要鏈接服務器就會遇到麻煩。 connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); write(sockfd, str, strlen(str)); n = read(sockfd, buf, MAXLINE); printf("Response from server:\n"); write(STDOUT_FILENO, buf, n); printf("\n"); close(sockfd); return 0; }
把上面的代碼保存到文件 client.c 文件中,並執行下面的命令編譯:
$ gcc client.c -o client
而後運行編譯出來的 client 程序:
$ ./client hello
此時服務器端會收到請求並返回轉換爲大寫的字符串,並輸出相應的信息:
而客戶端在發送請求後會收到轉換過的字符串:
在客戶端的代碼中有兩點須要注意:
1. 因爲客戶端不須要固定的端口號,所以沒必要調用 bind(),客戶端的端口號由內核自動分配。
2. 客戶端須要調用 connect() 鏈接服務器,connect 和 bind 的參數形式一致,區別在於 bind 的參數是本身的地址,而 connect 的參數是對方的地址。
至此咱們已經使用 socket 技術完成了一個最簡單的客戶端服務器程序,雖然離實際應用還很是遙遠,但就學習而言已經足夠了。
雖然咱們的服務器程序能夠響應客戶端的請求,可是這樣的效率過低了。通常狀況下服務器程序須要可以同時處理多個客戶端的請求。能夠經過 fork 系統調用建立子進程來處理每一個請求,下面是大致的實現思路:
listenfd = socket(...); bind(listenfd, ...); listen(listenfd, ...); while (1) { connfd = accept(listenfd, ...); n = fork(); if (n == -1) { perror("call to fork"); exit(1); } else if (n == 0) { // 在子進程中處理客戶端的請求。 close(listenfd); while (1) { read(connfd, ...); ... write(connfd, ...); } close(connfd); exit(0); } else { close(connfd); } }
此時父進程的任務就是不斷的建立子進程,而由子進程去響應客戶端的具體請求。經過這種方式,能夠極大的提高服務器端的響應能力。
本文經過一個簡單的建基於 TCP 協議的網絡程序介紹了 linux socket 編程中的基本概念。經過它咱們能夠了解到 socket 程序工做的基本原理,以及一些解決性能問題的思路。
參考:
基於TCP協議的網絡程序