Linux下Socket開發簡易Tcp伺服器

Linux下網路編程幾乎所有應用都是採用socket,了解一下socket的應用很有必要,記錄一下使用socket開發一個tcp伺服器的過程和實例。

Socket是什麼?

socket起源於Unix,而Unix/Linux基本哲學之一就是「一切皆文件」,都可以用「打開open –> 讀寫write/read –> 關閉close」模式來操作。

即網路通信是通過socket來進行的,而socket就是操作一種特殊的文件來實現的。

Socket是應用層與TCP/IP協議族通信的中間軟體抽象層,它是一組介面。

即Socket提供了操作上述特殊文件的介面,使用這些介面可以實現網路編程。

TCP和UDP

TCP(Transmission Control Protocol,傳輸控制協議是面向連接的協議,在正式通信之前必須建立起連接。UDP(User Data Protocol,用戶數據報協議)是一個非連接的協議。因此TCP的伺服器模式比UDP的伺服器模式多了listen,accept函數。TCP客戶端比UDP客戶端多了connect函數。

TCP使用socket創建伺服器端一般步驟是:

1、創建一個socket,用函數socket();

2、綁定IP地址、埠等信息到socket上,用函數bind();

3、開啟監聽,用函數listen();

4、接收客戶端上來的連接,用函數accept();

5、收發數據,用函數send()和recv(),或者read()和write();

6、關閉網路連接;

7、關閉監聽;

TCP使用socket創建客戶端一般步驟是:

1、創建一個socket,用函數socket();

2、連接伺服器,用函數connect();

3、收發數據,用函數send()和recv(),或者read()和write();

4、關閉網路連接;

UDP使用socket創建伺服器端一般步驟是:

1、創建一個socket,用函數socket();

2、綁定IP地址、埠等信息到socket上,用函數bind();

3、循環接收數據,用函數recvfrom();

4、關閉網路連接;

UDP使用socket創建客戶端一般步驟是:

1、創建一個socket,用函數socket();

2、設置對方的IP地址和埠等屬性;

3、發送數據,用函數sendto();

4、關閉網路連接;

使用Socket開發簡易Tcp伺服器

socket基本函數介紹:

int socket(int family, int type, int protocol); //返回socket描述字n

family:協議族。常用的協議族有,AF_INET(IPV4)、AF_INET6(IPV6)、AF_LOCAL(或稱AF_UNIX,Unix域socket)、AF_ROUTE等。

type:指定socket類型。常用的socket類型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等。

protocol:指定協議。常用的協議有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它們分別對應TCP傳輸協議、UDP傳輸協議、STCP傳輸協議、TIPC傳輸協議。

int bind(int sock_fd, const struct sockaddr *addr, socklen_t addr_len);n

sock_fd:socket描述字。

addr:struct sockaddr類型的數據結構,由於struct sockaddr數據結構類型不方便設置,所以通常會通過對truct sockaddr_in進行地質結構設置,然後進行強制類型轉換成struct sockaddr類型的數據。

addr_len:對應的地址的長度。

int listen(int sock_fd, int conn_num);n

conn_num:相應socket可以排隊的最大連接個數。

int connect(int sock_fd, struct sockaddr *addr,int addr_len);n

addr:連接目標伺服器的協議族。

addr_len:對應的地址的長度。

int accept(int sock_fd, struct sockaddr *addr, socklen_t *addr_len); //返回連接描述字n

sock_fd:socket描述字。

client_addr:結果參數,用來接受一個返回值,這返回值指定客戶端的地址,不需要可設置NULL。

addr_len:client_addr的長度。

C代碼:

#include <stdio.h>n#include <stdlib.h>n#include <string.h>n#include <errno.h>n#include <sys/socket.h>n#include <netinet/in.h>n#include <unistd.h>nn#define BUF_SIZE 200nnint main(int argc, char *argv[])n{ntint sock_fd, conn_fd;ntstruct sockaddr_in server_addr;ntchar buff[BUF_SIZE];ntint ret;ntint port_number = 2047;nnt// 創建socket描述符ntif ((sock_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {nttfprintf(stderr,"Socket error:%sna", strerror(errno));nttexit(1);nt}nnt// 填充sockaddr_in結構ntbzero(&server_addr, sizeof(struct sockaddr_in));ntserver_addr.sin_family = AF_INET;ntserver_addr.sin_addr.s_addr = htonl(INADDR_ANY);ntserver_addr.sin_port = htons(port_number);nnt// 綁定sock_fd描述符ntif (bind(sock_fd, (struct sockaddr *)(&server_addr), sizeof(struct sockaddr)) == -1) {nttfprintf(stderr,"Bind error:%sna", strerror(errno));nttexit(1);nt}nnt// 監聽sock_fd描述符ntif(listen(sock_fd, 5) == -1) {nttfprintf(stderr,"Listen error:%sna", strerror(errno));nttexit(1);nt}nntwhile(1) {n ntt// 接受請求nttif ((conn_fd = accept(sock_fd, (struct sockaddr *)NULL, NULL)) == -1) {ntttprintf("accept socket error: %sna", strerror(errno));ntttcontinue;ntt}nnttwhile(1) {nttt// 接受數據ntttret = recv(conn_fd, buff, BUF_SIZE, 0);ntttif (ret <= 0) {ntttt// 客戶端關閉nttttprintf("client closen");nttttclose(conn_fd);nttttbreak;nttt} else {nntttt// 添加結束符nttttif (ret < BUF_SIZE) {ntttttmemset(&buff[ret], 0, 1);ntttt}nttttprintf("recv msg from client: %sn", buff);nntttt// 發送數據nttttsend(conn_fd, "Hello", 6, 0);nttt}ntt}nttclose(conn_fd);nt}nntclose(sock_fd);ntexit(0);n}n

以上就實現了一個簡易的tcp伺服器,但該伺服器是單進程阻塞的,一個時間內只能使用處理一個客戶端,一般伺服器都是同時連接著多個客戶端,早期伺服器都是通過多進程/多線程解決並發IO問題。

多進程模式:

接受到一個請求就新開一個進程去處理數據。

進程函數:

pid_t fork(void);n

pid_t:調用fork會生成一個子進程,從返回值處繼續往下執行,有兩次返回值,父進程一次返回值,子進程一次返回值(結果為0)。

C代碼(多進程):

差異代碼:

while(1) {n // 接受請求n if ((conn_fd = accept(sock_fd, (struct sockaddr *)NULL, NULL)) == -1) {n printf("accept socket error: %sna", strerror(errno));n continue;n }nn // 開子進程處理數據n if (fork() == 0) {n while(1) {n // 接受數據n ret = recv(conn_fd, buff, BUF_SIZE, 0);n if (ret <= 0) {n // 客戶端關閉n printf("client closen");n close(conn_fd);n break;n } else {nn // 添加結束符n if (ret < BUF_SIZE) {n memset(&buff[ret], 0, 1);n }n printf("recv msg from client: %sn", buff);nn // 發送數據n send(conn_fd, "Hello", 6, 0);n }n }n close(conn_fd);n exit(0);n }n}nnclose(sock_fd);nexit(0);n

多線程模式:

接受到一個請求就新開一個線程去處理數據。

線程函數:

int pthread_create((pthread_t *thread, pthread_attr_t *attr, void *(*start_routine)(void *), void *argn

hread:線程標識符;

attr:線程屬性設置,不需要設置為NULL;

start_routine:線程函數的起始地址,即線程處理函數;

arg:傳遞給start_routine的參數;

C代碼(多線程):

引入頭部:

#include <pthread.h>n

差異代碼:

while(1) {nn // 接受請求n if ((conn_fd = accept(sock_fd, (struct sockaddr *)NULL, NULL)) == -1) {n printf("accept socket error: %sna", strerror(errno));n continue;n }n n // 開線程處理數據n pthread_t thread_id;n if (pthread_create(&thread_id, NULL, (void *)(&handle_data), (void *)(&conn_fd)) == -1)n {n printf("pthread create error: %sna", strerror(errno));n break;n }n}nnclose(sock_fd);nexit(0);n

多線程處理函數:

static void handle_data(void * conn_fd) {ntint fd = *((int *) conn_fd);ntint ret;ntchar buff[BUF_SIZE];nntwhile(1) {nntt// 接受數據nttret = recv(fd, buff, BUF_SIZE, 0);nttif (ret <= 0) {nttt// 客戶端關閉ntttprintf("client closen");ntttclose(fd);ntttbreak;ntt} else {nnttt// 添加結束符ntttif (ret < BUF_SIZE) {nttttmemset(&buff[ret], 0, 1);nttt}ntttprintf("recv msg from client: %sn", buff);nnttt// 發送數據ntttsend(fd, "Hello", 6, 0);ntt}nt}ntclose(fd);nt// 線程退出ntpthread_exit(NULL);n}n

多進程和多線程的模式可以扛住一定的並發,但Linux下開啟一個進程或線程都需要開闢空間並造成很大的開銷,對於像Nginx這種同時能接受成千上萬個連接顯然不適宜,這時需要使用到IO多路復用。

select模式:

用於IO復用,它用於監視多個文件描述符集合,看規定時間內有沒有事件產生。

select函數:

int select(int nfds,fd_set *readfds,fd_set *writefds,fd_set *exceptfds,struct timeval *timeout);n

nfds:是一個整型變數,其值是加入到後面三個文件描述符集合中的最大文件描述符的值加1。

readfds:可讀文件描述符集合,通過FD_SET向該文件描述符集合中加入需要監視的目標文件描述符,當有符合要求的文件描述符時,select會返回一個大於0的值,同時會把原來集合中的不可讀的文件描述符清掉,如果想在次監視可讀文件描述,需要重新FD_SET。

writefds:可寫文件描述符集合,同樣通過FD_SET函數向結合中加入需要被監視的目標文件描述符,select返回時,同樣會把不可寫文件描述符清掉,如果需要重新監視文件描述符,需要重新FD_SET設置。

exceptfds:該描述符集合是用於監視文件描述符集合中的任何文件是否發生錯誤。

timeout:用於設置超時的最長等待時間,如果在該規定時間內沒有返回一個大於0的值,則返回0,表示超時。如果超時間設置為NULL,表示阻塞等待,直到符合條件的文件描述符在集合中出現,當timeout的值為0時,select會立即返回。

timeout數據結構:

struct timevaln{n time_t tv_sec; /*秒*/n long tv_usec; /*微秒*/n};n

操作有4個宏可以操作文件描述符集合:

FD_ZERO: 用於清空文件描述符集合,FD_ZERO(&fds)。

FD_SET:向某個文件描述符結合中加入文件描述符, FD_SET(fd, &fds)。

FD_CLR:從某個文件描述符結合中取出某個文件描述符, FD_CLR(fd, &fds)。

FD_ISSET:測試某個文件描述符是否在某個文件描述符集合中, FD_ISSET(fd, &fds)。

C代碼(select):

引入頭部:

#include <arpa/inet.h>n#include <sys/types.h>n#include <netdb.h>n

代碼差異:

while(1) {nn // 初始化文件描述符集n FD_ZERO(&fdsr);nn // 將socket描述符添加到文件描述符集n FD_SET(sock_fd, &fdsr);nn // 將活動的連接添加到文件描述符集n for (i = 0; i < conn_num; i++) {n if (fd_A[i] != 0) {n FD_SET(fd_A[i], &fdsr);n }n }nn // 獲取文件描述符集中活躍的連接,沒有將堵塞直到超時n ret = select(max_sock + 1, &fdsr, NULL, NULL, &tv);n if (ret < 0) {n perror("select errorn");n break;n } else if (ret == 0) {n printf("timeoutn");n continue;n }nn // 檢測連接集合裡面每個連接是否活躍狀態n for (i = 0; i < conn_amount; i++) {n if (FD_ISSET(fd_A[i], &fdsr)) {nn // 接受數據n ret = recv(fd_A[i], buff, BUF_SIZE, 0);n if (ret <= 0) {n // 客戶端關閉n printf("client[%d] closen", i);n close(fd_A[i]);n FD_CLR(fd_A[i], &fdsr);n fd_A[i] = 0;n } else {nn // 添加結束符n if (ret < BUF_SIZE) {n memset(&buff[ret], 0, 1);n }n printf("client[%d] send:%sn", i, buff);nn // 發送數據n send(fd_A[i], "Hello", 6, 0);n }n }n }nn // 新的連接n if (FD_ISSET(sock_fd, &fdsr)) {n conn_fd = accept(sock_fd, (struct sockaddr *)NULL, NULL);n if (conn_fd <= 0) {n perror("accept errorn");n continue;n }nn // 將新連接添加到文件描述符集n if (conn_amount < conn_num) {n fd_A[conn_amount++] = conn_fd;n printf("new connection client[%d] %s:%dn", conn_amount,n inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));n if (conn_fd > max_sock)n max_sock = conn_fd;n }n else {n printf("max connections arrive, exitn");n send(conn_fd, "bye", 4, 0);n close(conn_fd);n break;n }n }nn // 查看當前客戶端狀態n printf("client amount: %dn", conn_amount);n for (i = 0; i < conn_num; i++) {n printf("[%d]:%d ", i, fd_A[i]);n }n printf("nn");n}nn// 關閉連接nfor (i = 0; i < conn_num; i++) {n if (fd_A[i] != 0) {n close(fd_A[i]);n }n}nexit(0);n

select模型的缺點有如下:1.最大並發限制,因為一個進程所打開的 FD (文件描述符)是有限制的,由 FD_SETSIZE 設置,默認值是 1024/2048。2.每次調用都會線性掃描全部的 FD 集合。poll解決了最大並發限制問題,但效率和select差不多。而epoll函數即沒有最大並發限制,也不需要掃描全部的FD集合。

epoll模式:

epoll是Linux內核為處理大批句柄而作改進的poll,是Linux下多路復用IO介面select/poll的增強版本,它能顯著的減少程序在大量並發連接中只有少量活躍的情況下的系統CPU利用率。因為它會復用文件描述符集合來傳遞結果而不是迫使開發者每次等待事件之前都必須重新準備要被偵聽的文件描述符集合。

epoll相關函數:

int epoll_create(int size)n

創建一個epoll句柄,參數size(現在已不用,只在創建時)用來告訴內核監聽的數目。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)n

epfd: 為epoll的句柄(即 epoll_create創建的epoll專用文件描述符);

op:表示動作,用3個宏來表示:

EPOLL_CTL_ADD(註冊新的fd到epfd)

EPOLL_CTL_MOD(修改已經註冊的fd的監聽事件)

EPOLL_CTL_DEL(從epfd刪除一個fd)

fd:需要監聽的標示符;

event:告訴內核需要監聽的事件,其中events可以用以下幾個宏的集合:

EPOLLIN :表示對應的文件描述符可以讀(包括對端SOCKET正常關閉)

EPOLLOUT:表示對應的文件描述符可以寫

EPOLLPRI:表示對應的文件描述符有緊急的數據可讀(這裡應該表示有帶外數據到來)

EPOLLERR:表示對應的文件描述符發生錯誤

EPOLLHUP:表示對應的文件描述符被掛斷;

EPOLLET: 將EPOLL設為邊緣觸發(Edge Triggered)模式,這是相對於水平觸發(Level Triggered)來說的

EPOLLONESHOT:只監聽一次事件,當監聽完這次事件之後,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL隊列里

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout) // 返回需要處理的事件數目,如返回0表示已超時n

events:用來從內核得到事件的集合

maxevents:告訴內核這個events有多大,這個maxevents的值不能大於創建epoll_create()時的size

timeout:超時時間(毫秒,0會立即返回,-1將不確定,也有說法說是永久阻塞

C代碼(epoll):

引入頭部:

#include <arpa/inet.h>n#include <sys/types.h>n#include <poll.h>n#include <sys/epoll.h>n

差異代碼:

while(1) {nnt// 獲取epoll監聽中活躍的描述符ntint active_num = epoll_wait(epollfd, eventList, conn_num, timeout);ntprintf ( "active_num: %dn", active_num);ntif(active_num < 0) {nttprintf("epoll wait errorn");nttbreak;nt} else if(active_num == 0) {nttprintf("timeout ...n");nttcontinue;nt}nnnt//直接獲取了事件數量,給出了活動的流,這裡是和poll區別的關鍵ntfor(i = 0; i < active_num; i++) {nntt// 非可讀跳過nttif (!(eventList[i].events & EPOLLIN)) {ntttprintf ( "event: %dn", eventList[i].events);ntttcontinue;ntt}nntt// 判斷是否新連接nttif (eventList[i].data.fd == sock_fd) {ntttconn_fd = accept(sock_fd, (struct sockaddr *)NULL, NULL);nntttif (conn_fd < 0) {nttttprintf("accept errorn");nttttcontinue;nttt}ntttprintf("Accept Connection: %dn", conn_fd);nnttt//將新建立的連接添加到epoll的監聽中ntttstruct epoll_event event;ntttevent.data.fd = conn_fd;ntttevent.events = EPOLLIN|EPOLLET;ntttepoll_ctl(epollfd, EPOLL_CTL_ADD, conn_fd, &event);ntt} else {nnttt// 接受數據ntttret = recv(eventList[i].data.fd, buff, BUF_SIZE, 0);ntttif (ret <= 0) {ntttt// 客戶端關閉nttttprintf("client[%d] closen", i);nttttclose(eventList[i].data.fd);nntttt// 刪除監聽nttttepoll_ctl(epollfd, EPOLL_CTL_DEL, eventList[i].data.fd, NULL);nttt} else {nntttt// 添加結束符nttttif (ret < BUF_SIZE) {ntttttmemset(&buff[ret], 0, 1);ntttt}nttttprintf("client[%d] send:%sn", i, buff);nntttt// 發送數據nttttsend(eventList[i].data.fd, "Hello", 6, 0);nttt}ntt}nt}n}nn// 關閉連接nclose(epollfd);nclose(sock_fd);nexit(0);n

上述demo可以戳這裡:demo

總結:

介紹了一下socket在tcp方面的簡單運用,在實際過程中,處理並發的方式都是多種模式並用,如IO復用加多進程或多線程,而像我們一般常用的web伺服器實現過程就很加的複雜,以上用於簡單學習,後面有時間還會寫寫特定語言的使用。


推薦閱讀:

從TCP三次握手說起--淺析TCP協議中的疑難雜症(2)
能不能不使用Socket進行網路通信?
如何解決長城寬頻主動斷開tcp長連接的問題?
epoll非阻塞伺服器,在20k並發測試結束產生大量establish狀態假連接,可能原因?
用 wireshark抓包工具能做到哪些有趣的事情?

TAG:Socket | TCP | Web开发 |