?TCP客戶端邏輯
void Usage(const std::string & process) {std::cout << "Usage: " << process << " server_ip server_port" <<std::endl;
}
// ./tcp_client serverip serverport
int main(int argc, char * argv[]) {if (argc != 3) {Usage(argv[0]);return 1;}std::string serverip = argv[1];uint16_t serverport = stoi(argv[2]);// 1. 創建 socketint sockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0) {cerr << "socket error" << endl;return 1;}// 要不要 bind?// 2. connectstruct sockaddr_in server;memset( & server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(serverport);// p:process(進程), n(網絡) -- 不太準確,但是好記憶inet_pton(AF_INET, serverip.c_str(), & server.sin_addr); // 1. 字符串 ip->4 字節 IP 2. 網絡序列int n = connect(sockfd, CONV( & server), sizeof(server)); // 自動進行 bindif (n < 0) {cerr << "connect error" << endl;return 2;}// 未來我們就用 connect過后的的sockfd 進行通信即可while(true) {string inbuffer;cout << "Please Enter# ";getline(cin, inbuffer);ssize_t n = write(sockfd, inbuffer.c_str(),inbuffer.size());if (n > 0) {char buffer[1024];ssize_t m = read(sockfd, buffer, sizeof(buffer) - 1);if (m > 0) {buffer[m] = 0;cout << "get a echo messsge -> " << buffer <<endl;} else if (m == 0 || m < 0) {break;}} else {break;}}close(sockfd);return 0;
}
1.創建tcp套接字,創建服務器監聽套接字地址結構體
我們是沒有給客戶端的通信套接字綁定地址的,要注意,我們不能手動bind客戶端通信套接字,為什么?
手動綁定意味著客戶端通信套接字的ip和端口是死的,那服務器端監聽套接字的ip和端口也是死的,那假如創建多個客戶端,則三次握手后這幾個連接的五元組都是一樣的,發送消息最后就無法正確給到套接字,玩不了一點
那咋整,有個接口叫個connect,你傳給它套接字描述符,它來給你自動綁定套接字地址(ip和端口),這樣最后每個連接的五元組都不會一樣了
2.接下來調用connect(sockfd, CONV( & server), sizeof(server))
這個接口會先給客戶端套接字綁定地址(ip和端口),然后會發起三次握手建立連接,建立連接之后,客戶端通信套接字就用五元組(協議,源ip,源port,目的ip,目的port)來標識
3.因為是有連接的,所以和udp相比起來就是省點事,udp無連接,所以要用sendto,recvfrom來發送和接收消息,但是tcp不需要,直接用write和read就行
TCP多進程版本服務器
const static int default_backlog = 6;
// TODO
class TcpServer: public nocopy {public: TcpServer(uint16_t port): _port(port),_isrunning(false) {}// 都是固定套路void Init() {// 1. 創建 socket, 本質是文件_listensock = socket(AF_INET, SOCK_STREAM, 0);if (_listensock < 0) {lg.LogMessage(Fatal, "create socket error, errnocode: % d, error string: % s\ n ", errno, strerror(errno));exit(Fatal);}int opt = 1;setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR |SO_REUSEPORT, & opt, sizeof(opt));lg.LogMessage(Debug, "create socket success,sockfd: % d\ n ", _listensock);// 2. 填充本地網絡信息并 bindstruct sockaddr_in local;memset( & local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = htonl(INADDR_ANY);// 2.1 bindif (bind(_listensock, CONV( & local), sizeof(local)) != 0) {lg.LogMessage(Fatal, "bind socket error, errnocode: % d, error string: % s\ n ", errno, strerror(errno));exit(Bind_Err);}lg.LogMessage(Debug, "bind socket success, sockfd: %d\n",_listensock);// 3. 設置 socket 為監聽狀態,tcp 特有的if (listen(_listensock, default_backlog) != 0) {lg.LogMessage(Fatal, "listen socket error, errno code: % d, error string: % s\ n ", errno, strerror(errno));exit(Listen_Err);}lg.LogMessage(Debug, "listen socket success,sockfd: % d\ n ", _listensock);}// Tcp 連接全雙工通信的.void Service(int sockfd) {char buffer[1024];// 一直進行 IOwhile (true) {ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);if (n > 0) {buffer[n] = 0;std::cout << "client say# " << buffer <<std::endl;std::string echo_string = "server echo# ";echo_string += buffer;write(sockfd, echo_string.c_str(),echo_string.size());} else if (n == 0) // read 如果返回值是 0,表示讀到了文件結尾(對端關閉了連接!) {lg.LogMessage(Info, "client quit...\n");break;} else {lg.LogMessage(Error, "read socket error, errnocode: % d, error string: % s\ n ", errno, strerror(errno));break;}}}void ProcessConnection(int sockfd, struct sockaddr_in & peer) {// v2 多進程pid_t id = fork();if (id < 0) {close(sockfd);return;} else if (id == 0) {// childclose(_listensock);if (fork() > 0)exit(0);InetAddr addr(peer);// 獲取 client 地址信息lg.LogMessage(Info, "process connection: %s:%d\n",addr.Ip().c_str(), addr.Port());// 孫子進程,孤兒進程,被系統領養,正常處理Service(sockfd);close(sockfd);exit(0);} else {close(sockfd);pid_t rid = waitpid(id, nullptr, 0);if (rid == id) {// do nothing}}}void Start() {_isrunning = true;while (_isrunning) {// 4. 獲取連接struct sockaddr_in peer;socklen_t len = sizeof(peer);int sockfd = accept(_listensock, CONV( & peer), & len);if (sockfd < 0) {lg.LogMessage(Warning, "accept socket error, errnocode: % d, error string: % s\ n ", errno, strerror(errno));continue;}lg.LogMessage(Debug, "accept success, get n newsockfd: % d\ n ", sockfd);ProcessConnection(sockfd, peer);}}~TcpServer() {}private:uint16_t _port;int _listensock;// TODObool _isrunning;
};
1.創建一個套接字,給套接字設置選項,使其可以復用地址,創建一個套接字地址結構體,ip填INADDR_ANY,port由構造TcpServer時給定,然后bind將套接字綁定地址,還沒結束,將該套接字設置為監聽套接字,從此,該套接字就和udp套接字一樣,是沒有連接,這個套接字的作用就是三次握手建立連接用的
2.start是主邏輯,創建一個套接字地址結構體,這是用來接收客戶端套接字ip和端口的,接下來accept會從監聽套接字全連接隊列里選擇一個完成三次握手的連接,然后構建連接對應的通信套接字,返回其套接字文件描述符,順帶要說的是,通信套接字和監聽套接字綁定的地址是一樣的,但監聽套接字無連接,通信套接字有連接,所以是可以通過元組區分的
3.獲得通信套接字后傳參到ProcessConnection,主進程也就是父進程會創建子進程,然后父進程關閉通信套接字的文件描述符,子進程關閉監聽套接字描述符,然后父進程阻塞等待回收子進程,子進程創建孫子進程,然后子進程退出釋放其資源,父進程回收子進程釋放子進程本身數據結構,之后父進程繼續accept獲取新連接,孫子進程因為子進程的退出變成了孤兒進程,孤兒進程最終由INIT進程負責回收,但在退出之前,該孫子進程將負責與客戶端的通信
TCP服務器多線程版本
const static int default_backlog = 6;
// TODOclass TcpServer : public nocopy {public:TcpServer(uint16_t port) : _port(port), _isrunning(false) {}// 都是固定套路void Init() {// 1. 創建 socket, file fd, 本質是文件_listensock = socket(AF_INET, SOCK_STREAM, 0);if (_listensock < 0) {lg.LogMessage(Fatal, "create socket error, errno
code: %d, error string: %s\n", errno, strerror(errno));exit(Fatal);}int opt = 1;setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR |SO_REUSEPORT, &opt, sizeof(opt));lg.LogMessage(Debug, "create socket success,
sockfd: %d\n", _listensock);// 2. 填充本地網絡信息并 bindstruct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = htonl(INADDR_ANY);// 2.1 bindif (bind(_listensock, CONV(&local), sizeof(local)) != 0) {lg.LogMessage(Fatal, "bind socket error, errno
code: %d, error string: %s\n", errno, strerror(errno));exit(Bind_Err);}lg.LogMessage(Debug, "bind socket success, sockfd: %d\n",_listensock);// 3. 設置 socket 為監聽狀態,tcp 特有的if (listen(_listensock, default_backlog) != 0) {lg.LogMessage(Fatal, "listen socket error, errno
code: %d, error string: %s\n", errno, strerror(errno));exit(Listen_Err);}lg.LogMessage(Debug, "listen socket success,
sockfd: %d\n", _listensock);}class ThreadData {public:ThreadData(int sockfd, struct sockaddr_in addr): _sockfd(sockfd), _addr(addr) {}~ThreadData() {}public:int _sockfd;InetAddr _addr;};// Tcp 連接全雙工通信的.// 新增 staticstatic void Service(ThreadData &td) {char buffer[1024];// 一直進行 IOwhile (true) {ssize_t n = read(td._sockfd, buffer, sizeof(buffer) -1);if (n > 0) {buffer[n] = 0;std::cout << "client say# " << buffer <<std::endl;std::string echo_string = "server echo# ";echo_string += buffer;write(td._sockfd, echo_string.c_str(),echo_string.size());} else if (n == 0) // read 如果返回值是 0,表示讀到了文件結尾(對端關閉了連接!) {lg.LogMessage(Info, "client[%s:%d] quit...\n",td._addr.Ip().c_str(), td._addr.Port());break;} else {lg.LogMessage(Error, "read socket error, errno
code: %d, error string: %s\n", errno, strerror(errno));break;}}}static void *threadExcute(void *args) {pthread_detach(pthread_self());ThreadData *td = static_cast<ThreadData *>(args);TcpServer::Service(*td);close(td->_sockfd);delete td;return nullptr;}void ProcessConnection(int sockfd, struct sockaddr_in &peer) {// v3 多線程InetAddr addr(peer);pthread_t tid;ThreadData *td = new ThreadData(sockfd, peer);pthread_create(&tid, nullptr, threadExcute, (void*)td);}void Start() {_isrunning = true;while (_isrunning) {// 4. 獲取連接struct sockaddr_in peer;socklen_t len = sizeof(peer);int sockfd = accept(_listensock, CONV(&peer), &len);if (sockfd < 0) {lg.LogMessage(Warning, "accept socket error, errno
code: %d, error string: %s\n", errno, strerror(errno));continue;}lg.LogMessage(Debug, "accept success, get n new
sockfd: %d\n", sockfd);ProcessConnection(sockfd, peer);}}~TcpServer() {}private:uint16_t _port;int _listensock;// TODObool _isrunning;
};
1、2.過程和多進程的一樣,咱們直接看accept出新連接后怎么辦
3.accept得到通信套接字后,將通信套接字描述符和套接字地址傳參到ProcessConnection,創建ThreadData結構體,因為線程執行函數只有一個參數,想要將通信套接字描述符和套接字地址都傳過去就只能封裝一下,然后pthread_create創建新線程,線程又叫輕量級進程,依靠時間片并發交替運行,主線程執行完ProcessConnection后就是繼續去接收新連接去了,子線程執行threadExcute,先detach使該線程退出無需主線程回收,然后將封裝的結構轉回類型,并利用套接字描述符進行通信
TCP底層詳細剖析
socket接口
前兩個參數分別指定套接字的IP層協議和傳輸層協議,第三個參數沒用,然后函數會創建對應類型套接字的所有數據結構,包括struct file,struct socket,struct sock,然后返回該套接字的描述符
bind接口
給套接字綁定地址,也就是ip和端口,傳參用的是套接字地址結構體,一般填寫地址族,ip,port字段就完了,這個接口服務器在創建listen套接字時會用到,而客戶端不能自己綁定,不然多個連接最后五元組是一樣的,沒得玩
listen接口
將一個套接字聲明為監聽套接字,從此這個套接字會像udp套接字一樣是無連接的,即使socket創建它時是用的TCP協議。它將專門用來進行三次握手,當然三次握手是靠硬件中斷推動,但listen套接字絕對是其數據結構基礎,第二個參數用來描述listen套接字的全連接隊列的大小,linux中這個隊列大小是backlog+1
connect接口
先給客戶端套接字隨機綁定地址(ip加端口),然后封裝SYN包,交給IP層,IP層封裝報頭,然后根據目的ip查路由表,得到下一跳ip和發送接口,一同交給數據鏈路層,網卡驅動根據下一跳ip查找arp緩存得到mac地址,然后添加mac頭和校驗位,寫進發送接口對應網卡的發送緩沖區,然后寫網卡的TDT寄存器,網卡用DMA讀出數據HVY轉換數據接口發出去,注意,此時報文中目的ip端口和源ip端口都很直接,就是那倆套接字綁定的,但等下就不是了,假如客戶端是私網,服務器是公網,那下一跳很明顯是路由器lan口ip,路由器網卡接收數據寫進接收緩沖區,網卡觸發硬件中斷,中斷控制器使目標cpu陷入內核,執行中斷方法,從緩沖區讀數據,然后看幀類型是ip幀,那就檢查mac地址和crc校驗,無問題就交給IP層,IP層會用wan口ip和序列端口替換源ip和端口,并且在地址轉換表里記錄(源ip,源port,目的ip,目的port)和(新ip,新port,目的IP,目的port)的映射,然后查路由表,確定下一跳ip和發送網卡,交給數據鏈路層發出去,當這個報文到達服務器后,網卡接收然后寫進緩沖區,觸發硬件中斷,cpu執行中斷方法,網卡驅動讀出數據,根據幀類型是ip幀,需要檢查校驗位然后交給ip層,IP層根據報頭里的協議類型交給tcp層,tcp層一看是syn報文,那就查三元組,這也是為什么服務器需要比客戶端提前啟動的原因了,服務器已經創建好了監聽套接字并且accept擱那里阻塞,服務器進程在accept的全連接隊列上阻塞,然后我們接著聊這次網卡的硬件中斷,tcp層查三元組(協議,目的ip,目的端口)找到了監聽套接字,那還說啥,在監聽套接字的半連接隊列上創建struct? request_sock,然后構建ack+syn捎帶應答tcp報文,交給ip層,IP層填充ip報頭,這個響應報文的四元組其實就是發送過來時ip和端口將’‘源‘’和‘’目的‘’反過來填就是了,從這里我們也可以知道,服務器這邊的通信套接字的五元組其源ip和源port其實是廣域網路由器的,并不是客戶端主機的,而客戶端主機那里的則是雙方主機的(我的心里有你,而你只看見了中間的那個,就這樣記),接著聊,緊接著交給數據鏈路層發送出去,路由器收到以后,到了ip層,根據地址轉換表里的記錄,將報文中的目的ip和端口改了,然后再根據新目的ip查路由表,后面一系列不再重復,最后交給客戶端主機,客戶端主機同樣網卡接收,硬件中斷處理,構建最后的ack然后重新發回去,發回去ack后客戶端的connect就結束了,接下來就是開始發消息,服務器收到ack后,在tcp層根據三元組找到監聽套接字,再將半連接隊列中的struct? request_sock給升級成全連接隊列里的struct? sock,喚醒全連接隊列等待隊列上的進程,服務器進程狀態被設置為R,并且被切換到運行隊列里,服務器繼續執行accept,將全連接隊列中的struct sock給創建對應的struct socket和struct file,然后返回套接字描述符,至此服務器accept也執行完畢,開始創建新進程或新線程和客戶端,并通過通信套接字進程通信了,這樣父進程或主線程只負責創建新連接,而不參與與客戶端的通信,這也是我為什么說監聽套接字是無連接的(就像一個海王,誰都可以來,那它誰也不愛)
write和read接口
還記得嗎,udp套接字無連接是三元組,那發消息和接收消息都不知道對方是誰,所以用的是sendto和recvfrom,需要加上套接字地址結構體,而我們tcp套接字,除了listen套接字是無連接的,其他的都是成對的五元組通信套接字,可以直接用write和read來發送和接受消息,因為有鏈接,所以知道對方是誰
close接口
客戶端這邊鍵盤按下ctrl+c,那鍵盤觸發硬件中斷,cpu陷入內核,經一系列操作后終端模擬器收到字符,將其顯示在終端上,然后write寫進主設備,終端驅動給客戶端設置SIGINT信號,然后喚醒客戶端,客戶端接下來處理時鐘中斷或軟中斷(write)等結束后,會處理信號,檢查struct thread_info里的標志發現被設置了TIF_SIGPEDING,于是調用do_signal,從頭開始遍歷pending表,找到第一個設置了pending有沒有block的信號,這里肯定是SIGINT了,那于是給cpu設置好新寄存器,然后切換到用戶態,執行信號處理函數,具體就是,pcb中信號碼設置為2,退出碼是0不管,進程狀態改為Z,從運行隊列中除去,釋放文件描述符表等資源,給父進程shell設置SIGCHLD信號然后喚醒wait的父進程,父進程根據客戶端pcb中的退出信息構建好返回status,然后釋放客戶端pcb本身空間,這都沒有問題,但我要說的是,在客戶端文件描述符表中每個文件描述符都被close,其中的的通信套接字在調close時,會向服務器發送fin包,然后釋放struct file和struct socket,只剩struct sock被OS管理,它還不能釋放,因為要完成四次揮手,我們以多進程的服務器來看,網卡收到消息后寫進網卡接收緩沖區,然后觸發硬件中斷往上解包,當收到fin包解包到傳輸層后,一看標志位是fin包,那就根據五元組找到套接字,然后將套接字的連接狀態改成CLOSE_WAIT,喚醒接收緩沖區等待隊列上的服務器進程,組裝ack包響應給客戶端,然后該次中斷就結束了,至于被喚醒的服務器進程,繼續執行read,緩沖區數據是0,然后看套接字狀態,是CLOSE_WAIT,那read就返回0,表示對面不會再發數據了,read返回值是零,我們再看下面的邏輯,close通信套接字,那于是自個套接字狀態改成LAST_ACK,并給客戶端發送fin包,然后釋放struct file和struct socket,struct sock由OS管理完成剩下四次揮手,然后服務器進程exit(0),那就是執行atexit()注冊的處理函數,然后把所有struct FILE的用戶級緩沖區都進行刷新,也就是調用對應系統調用,最后執行_exit(),設置pcb中退出碼為0,信號碼不管,狀態設S,移除運行隊列,清除資源,給父進程設SIGCHLD,這里服務器進程是孫子進程,也就是說父進程是INIT進程,然后INIT進程通過時鐘中斷定時wait僵尸進程,釋放其資源。客戶端收到ack,觸發硬件中斷,解包到tcp層,根據五元組找到套接字,將套接字狀態改成TIME_WAIT,然后發送ack包給服務器套接字,服務器網卡接口收到消息后觸發硬件中斷,往上解包到tcp層,根據五元組找到套接字,然后直接把struct sock釋放,至此,服務器端通信套接字徹底釋放,而客戶端套接字在兩分鐘內如果沒有收到FIN包,那說明ack包已經成功被服務器收到了,因此服務器沒有超時重傳fin包,這時客戶端套接字的struct sock也釋放了,非常完美。由此引入兩個問題,一個是TIME_WAIT狀態是要求在兩分鐘內不再受到fin包則認為對方收到ack包,假如在此兩分鐘內收到了fin包,那就是服務器沒收到ack所以超時重傳了,這時服務器端就刷新時間,并重新發送ack包。TIME_WAIT作為主動斷開連接的一方會進入的一個狀態,會導致這段時間會占用該地址端口,假如是客戶端那影響不大,因為客戶端每次是connect隨機綁定地址,而服務器則會因為舊套接字占用地址而重啟服務器創建監聽套接字后會bind失敗,怎么解決呢?那就是給套接字設置SO_REUSEADDR,這樣當一個地址被TIME_WAIT占據時,就允許一個且只允許一個活躍套接字bind這個地址,這就是端口復用,所以這就是為什么多進程服務器監聽套接字要設置端口復用
read和recvfrom的返回值
read和recvfrom的返回值邏輯相同,如果大于零那就是讀到的數據字節數,如果是-1那就是讀數據出錯,如果等于0那就是連接已斷開(tcp才會出現這個返回值,udp是不會出現的)
具體邏輯:udp的話如果有數據就讀數據,如果沒數據就阻塞等待;tcp的話如果有數據就讀數據,如果沒數據就檢查連接狀態,如果是ESTABLISHED那就阻塞等待,如果是CLOSE_WAIT那就返回0(對端主動關閉連接,也只能是對方主動關閉,如果是你主動關閉,那后面還擱這讀?)
端口復用
1.顯式?bind
?vs 隱式綁定??
??類型?? | ??定義?? | ??示例?? |
---|---|---|
??顯式?bind ?? | 通過?bind() ?系統調用明確綁定地址和端口 | bind(sockfd, &addr, sizeof(addr)) |
??隱式綁定?? | 由內核自動關聯地址 | accept() ?返回的通信套接字復用監聽套接字的地址 |
2.?SO_REUSEADDR
???
- ??允許綁定處于?
TIME_WAIT
?狀態的地址??。 - ??不允許多個活躍套接字同時綁定同一地址??。
- 主要解決服務器重啟問題
邊界條件驗證??
// 場景1:前一個套接字處于 TIME_WAIT
setsockopt(sock1, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(sock1, &addr, sizeof(addr)); // 成功(即使端口在 TIME_WAIT)// 場景2:前一個套接字仍活躍(未關閉)
setsockopt(sock2, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(sock2, &addr, sizeof(addr)); // 失敗(errno=EADDRINUSE)
3.?SO_REUSEPORT
???
- ??允許多個套接字同時顯式綁定同一地址??(
IP:PORT
)。 - ??隱含?
SO_REUSEADDR
?的功能??。 - 所有套接字都需要設置?
SO_REUSEPORT