使用帶外資料設定 C/S 心跳機制
首先,如果你還不瞭解什麼是帶外資料:點這裡
心跳機制的產生就是為了檢測出對端主機或到對端的通訊路徑是否過早失效。
注意:在使用心跳機制時,你應該考慮是不是符合你所處的情景,確定在對端應答的時間超過 5~10s 之後終止連線是件好事還是壞事。如果你的產品需要實時的知道對端的“生存狀態”,(要麼是為了需求,要麼是為了節省資源)那麼就是需要這種機制的。一般用於 長連線 。
在這裡,我們使用TCP
的帶外資料來完成心跳機制的實現(每秒鐘輪詢一次,若5秒沒有得到響應就認為對端已經“死亡”),實現如下所示 :
客戶端每隔1秒鐘向伺服器傳送一個帶外位元組,伺服器收到該型別的位元組然後再發送回一個帶外位元組。因為每一端都需要對端不復存在或者不再可達。需要指出的是:**資料,回送資料和帶外位元組都通過單個的連線交換的 。**程式碼實現如下,具體實現細節在程式碼中有註釋指出
- Recvline 函式
#include "../myhead.h"
static int recv_cnt = 0;
static char *recv_ptr = NULL;
static char recv_buf[MAXLINE];
static ssize_t my_recv(int fd, char *ptr, int flags)
{
if (recv_cnt <= 0)
{
again:
if ((recv_cnt = recv(fd, recv_buf, sizeof(recv_buf), flags)) < 0)
{
if (errno == EINTR)
goto again;
else
return (-1);
}
else if (recv_cnt == 0)
{
return (0);
}
recv_ptr = recv_buf;
}
recv_cnt--;
*ptr = *recv_ptr++;
return (1);
}
ssize_t recvline(int fd, void *vptr, size_t maxlen, int flags)
{
ssize_t n, rc;
char c, *ptr;
ptr = vptr;
for (n = 1; n < maxlen; n++)
{
if ((rc = my_recv(fd, &c, flags)) == 1)
{
*ptr++ = c;
if (c == '\n')
break;
}
else if (rc == 0)
{
*ptr = 0;
return (n - 1);
}
else
return (-1);
}
*ptr = 0;
return (n);
}
ssize_t Recvline(int fd, void *buf, size_t Maxlen, int flags)
{ //注意引數 Maxlen
ssize_t n;
if ((n = recvline(fd, buf, Maxlen, flags)) < 0)
err_sys("recvline error ");
return (n);
}
將recv_buf
寫做我們的一個緩衝區,recv_ptr
指向緩衝區下一個可被讀取的位元組,在my_recv
函式中複製給引數ptr
,表示讀取到一個位元組。recv_cnt
表示緩衝區剩餘位元組的數量 ,如果<=0
,就進行下一次的緩衝區讀取。如果在需要一個位元組一個位元組的處理資料時,比起一個位元組一個位元組的讀取,顯然這樣的方式是更有效率的 !!!
- 服務端主函式
serv_main.c
#include "../myhead.h"
#include "test.h"
void fun_serv(int connfd) //子程序執行函式
{
ssize_t n;
char line[MAXLINE];
heartbeat_serv(connfd, 1, 5);
for (;;)
{
if ((n = Recvline(connfd, line, MAXLINE, 0)) == 0)
{
printf("客 戶 端 關 閉 啦 !!!\n");
Close(connfd);
return ;
}
Sendlen(connfd, line, n, 0);
}
}
int main(int argc, char **argv)
{
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
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); //9877
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (int *)&opt, sizeof(int));
Bind(listenfd, (SA *)&servaddr, sizeof(servaddr));
Listen(listenfd, LISTENQ);
for (;;)
{
clilen = sizeof(cliaddr);
if ((connfd = Accept(listenfd, (SA *)&cliaddr, &clilen)) < 0)
{
if (errno == EINTR)
continue;
else
err_sys("accept error ");
}
if ((childpid = Fork()) == 0)
{
Close(listenfd);
printf(" 新的連線:connfd == %d \n", connfd);
fun_serv(connfd);
exit(0);
}
Close(connfd);
}
}
在我們fork
之後,父子程序都會有一個監聽套接字和一個連線套接字,另外,所有的套接字選項都會從監聽套接字傳承給連線套接字。因為我們只需要讓子程序去處理客戶端請求即可。所以我們在父程序中關閉連線套接字,讓他只去負責處理客戶連線。子程序中關閉監聽套接字,讓子程序去處理客戶請求。
- 服務端設定心跳包函式
heartbeat_serv.c
#include "../myhead.h"
static int servfd;
static int nsec;
static int maxnalarms;
static int nprobes; //統計 SIGALRM 數量
static void sig_urg(int), sig_alrm(int); // alarm 函式的使用是為了輪詢
void heartbeat_serv(int servfd_arg, int nsec_arg, int maxnalarms_arg) //fd 1 5
{
servfd = servfd_arg;
nsec = nsec_arg;
maxnalarms = maxnalarms_arg;
nprobes = 0;
signal(SIGURG, sig_urg);
Fcntl(servfd, F_SETOWN, getpid());
signal(SIGALRM, sig_alrm);
alarm(nsec);
}
static void sig_urg(int signo)
{
printf("產生 SIGURG 訊號 \n");
int n;
char ch;
if ((n = recv(servfd, &ch, 1, MSG_OOB)) < 0) //只要產生帶外資料,就說明客戶端主機是存活的
{
if (errno != EWOULDBLOCK)
err_sys("recv error");
}
else if (n > 0)
{
printf("伺服器接收到帶外資料,說明客戶端主機是存活的\n");
if (send(servfd, &ch, 1, MSG_OOB) > 0)
printf("伺服器傳送了帶外資料\n");
nprobes = 0;
}
return;
}
static void sig_alrm(int signo)
{
if (++nprobes > maxnalarms)
{
fprintf(stderr, " 此客戶端 gg,伺服器子程序退出 \n");
exit(0);
}
alarm(nsec);
return;
}
客戶端連線之後,由客戶端主動發起OOB
資料心跳,然後在服務端產生SIGURG
訊號,服務端處理該訊號和OOB
資料,並回送。如果已經產生了SIGURG
訊號,但是oob
數還沒有到達,recv
會返回EWOULDBLOCK
錯誤。nprobes
用來統計時間。
- 客戶端主函式
cli_main.c
#include "../myhead.h"
#include "test.h"
void fun_client(FILE *fp, int sockfd)
{
int maxfdp1, stdineof = 0;
fd_set rset;
char recvline[MAXLINE], sendline[MAXLINE];
int n;
heartbeat_cli(sockfd, 1, 5); // 1.呼叫函式
FD_ZERO(&rset);
for (;;)
{
if (stdineof == 0)
FD_SET(fileno(fp), &rset);
FD_SET(sockfd, &rset);
maxfdp1 = max(fileno(fp), sockfd) + 1;
if ((n = select(maxfdp1, &rset, NULL, NULL, NULL)) < 0)
{
if (errno == EINTR) // 2.處理 select
continue;
else
err_sys("select error");
}
if (FD_ISSET(sockfd, &rset))
{
if ((n = Recvline(sockfd, recvline, MAXLINE, 0)) == 0) //讀取一行,Readline 返回讀取到的位元組數
{
if (stdineof == 1)
return;
else
err_quit("fun_client:server terminated permaturely ");
}
write(STDOUT_FILENO, recvline, n); //3.呼叫 write 函式,而不是 fputs,見`UNP`
}
if (FD_ISSET(fileno(fp), &rset)) //可以進行輸入了
{
if (Fgets(sendline, MAXLINE, fp) == NULL)
{
stdineof = 1;
shutdown(sockfd, SHUT_WR);
FD_CLR(fileno(fp), &rset); // 在檔案描述符集合中刪除一個檔案描述符。
continue;
}
Sendlen(sockfd, sendline, strlen(sendline), 0);
}
}
}
int main(int argc, char **argv)
{
int sockfd;
struct sockaddr_in servaddr;
if (argc != 2)
{
printf("usage: tcpcli <IPaddress>\n");
return -1;
}
sockfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT); // 9877
inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
connect(sockfd, (SA *)&servaddr, sizeof(servaddr));
fun_client(stdin, sockfd);
exit(0);
}
使用select
來監聽兩個套接字,一個是連線套接字,一個是標準輸入。stdeof=0
,說明可以輸入。如果輸入結束,我們將stdeof
設為1,關閉對sockfd
的任何寫操作,刪除select
對應位。EINTR:當慢系統呼叫被訊號打斷時,慢系統呼叫會返回它。對比於下面一種簡單的寫法,他為什麼能處理伺服器崩潰的情況?因為在這裡,當讀到EOF
時,判斷了是否是正常結束(stdeof=1
正常,否則伺服器過早終止server terminated permaturely
)
- 客戶端設定心跳包函式
heartbeat_cli.c
#include "../myhead.h"
#include "test.h"
static int servfd;
static int nsec;
static int maxnprobes;
static int nprobes; //統計產生訊號`SIGALRM`的數量
static void sig_urg(int), sig_alrm(int); // alarm 函式的使用是為了輪詢
void heartbeat_cli(int servfd_arg, int nsec_arg, int maxnprobes_arg) //fd 1 5
{
//sleep(6);
servfd = servfd_arg;
nsec = nsec_arg;
maxnprobes = maxnprobes_arg;
nprobes = 0;
signal(SIGURG, sig_urg);
Fcntl(servfd, F_SETOWN, getpid());
signal(SIGALRM, sig_alrm);
alarm(nsec);
}
static void sig_urg(int signo)
{
printf("產生 SIGURG 訊號 \n");
int n;
char ch;
if ((n = recv(servfd, &ch, 1, MSG_OOB)) < 0) //只要產生帶外資料,就說明伺服器主機是存活的
{
if (errno != EWOULDBLOCK)
err_sys("recv error");
}
else if (n > 0)
{
printf("客戶端接收到帶外資料,說明伺服器主機是存活的\n");
nprobes = 0;
}
return;
}
static void sig_alrm(int signo)
{
if (++nprobes > maxnprobes)
{
fprintf(stderr, "此伺服器gg,客戶端直接退出 \n");
exit(0);
}
if( send(servfd, "1", 1, MSG_OOB) > 0 )
printf("客戶端傳送了帶外資料\n");
alarm(nsec);
return;
}
測試:
-
是否正確執行
-
伺服器超時響應:
-
回射伺服器正常執行:
- 某一端崩潰:
具體原始碼見我的github
:原始碼
討論:1. 為什麼不選用TCP 保持存活特性(SO_KEEPLIVE)來提供這種功能?
兩個原因:
SO_KEEPALIVE
選項預設是閒置2小時,傳送保持存活檢測段。可以改動時間,但是改動之後會影響所有的開啟該選項的套接字。SO_KEEPALIVE
也不是用來高頻率的輪詢的 。
2. 為什麼在發現對端 gg之後,直接退出,而不是選擇關閉套接字再退出?
因為對方已經gg
,close(fd)
會給對方傳送一個SYN
,自己身處於time_wait
狀態 ,而這是完全沒有必要的 。
3. Recvline 函式有什麼缺點?靜態資料有什麼缺點?
使用靜態資料會使得recvline
函式變得非執行緒安全了 。持續更新----------》
4. 像下面一樣寫,當伺服器崩潰時,客戶端會有什麼效果?
void fun_client00000(int connfd)
{
char sendline[MAXLINE] = {0};
char recvline[MAXLINE] = {0};
int tt = 0;
while (Fgets(sendline, MAXLINE, stdin) != NULL)
{
tt = Sendlen(connfd, sendline, strlen(sendline), 0);
if ( Recvline(connfd, recvline, sizeof(recvline), 0) == 0)
{
printf("服 務 器 關 閉 連 接 退 出 \n");
Close(connfd);
return;
}
Fputs(recvline, stdout);
}
}
int main(int argc, char **argv)
{
int sockfd;
struct sockaddr_in servaddr;
if (argc != 2)
{
printf("usage: tcpcli <IPaddress>\n");
return -1;
}
sockfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT); // 9877
inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
connect(sockfd, (SA *)&servaddr, sizeof(servaddr));
fun_client00000(sockfd);
exit(0);
}
客戶端首先阻塞於Fgets
,然後阻塞於Recvline
,如果一旦服務端崩潰,客戶端的Recvline
返回0,列印輸出資訊。但是,這裡遇到的一個問題就是“當伺服器崩潰時,只有按了回車鍵才會輸出列印資訊”,還沒有搞清楚這是為什麼? ~_~