程序整體框架#
簡介#
程序整體是一個單線程的非阻塞的程序,分為 client 和 server 兩個部分,可以將內網運行在特定端口(app port 比如 80)
的基於 TCP 協議的服務(app)映射到外網伺服器上的動態端口(mapping port 比如 44567)。為了實現 client 和 server
的通信,server 需要監聽一個端口(c-s port, 比如 7080)來和 client 通信。由於 NAT 存在的原因,最開始需要 client 向
server 監聽的端口(7080)發送一個 connect 來建立連接,並將這個連接作為 masterfd, 來管理 server 對 client 的連接發起請求。
收到 client 的請求後,server 將監聽一個動態端口(比如 44567),作為內網服務映射的端口,並通過 masterfd 將端口信息發給
客戶端。之後 server 每接受一個請求,就會通過 masterfd 向 client 請求一個連接,之後客戶端會建立一個 app port (比如 80)到
c-s port(比如 7080)的數據流轉發 tunnel, server 就會建立一個 c-s port 到 mapping port 的數據流轉發 tunnel, 兩者向連接
就可以完成 port forword 功能,並通過 epoll 管理多個這樣的 tunnel 的行為。
數據結構#
client 和 server 內部其實都在做著相同的事情,那就是一對 sock fd, 總是一端接受數據,然後再轉發到另一端。因此
可以抽象出一個對象,來完成這些操作。
// 對fd的包裝
// buf:該fd用來接收數據的緩衝區
// iptr, optr記錄數據讀寫位置的指針
// fd_fin, closed,connected:記錄fd狀態的變量
class fd_buf{
public:
int fd;
char buf[MAXLINE];
int iptr, optr;
bool fd_fin,closed,connected;
// uint32_t mod;
fd_buf() {
iptr = optr = 0;
fd_fin = false;
connected = false;
closed = false;
// mod = EPOLLIN;
}
fd_buf(int fd){
iptr = optr = 0;
fd_fin = false;
connected = false;
closed = false;
// mod = EPOLLIN;
this->fd = fd;
}
bool is_buf_empty() {
return iptr == optr;
}
bool is_buf_full() {
return iptr == MAXLINE;
}
};
// 用來完成數據轉發的對象
// 從用戶的角度看,一段為上行,一段為下載
class tunnel{
public:
fd_buf *fd_up, *fd_down;
double sum_up, sum_down;
tunnel() {
sum_up = 0;
sum_down = 0;
}
tunnel(int fd_user, int fd_app) {
sum_up = 0;
sum_down = 0;
fd_up = new fd_buf(fd_user);
fd_down = new fd_buf(fd_app);
}
int do_read(fd_buf* fd_buf_in, fd_buf* pair_fd_buf);
int do_write(fd_buf* fd_buf_out, fd_buf* pair_fd_buf);
void fd_user_read(){
int n = do_read(fd_up, fd_down);
sum_up += n;
}
void fd_app_read(){
do_read(fd_down, fd_up);
}
void fd_user_write(){
int n = do_write(fd_up, fd_down);
sum_down += n;
}
void fd_app_write(){
do_write(fd_down, fd_up);
}
bool is_user_fd(int fd){
return fd == fd_up->fd;
}
~tunnel() {
delete fd_down;
delete fd_up;
}
};
非阻塞#
為什麼會阻塞?因為對於單個連接而言,如果該階段的工作沒完成,下面的任務就不能正確完成,所以必須阻塞。為什麼
要設置非阻塞,因為我們要管理多個連接。在單線程的程序中,如果一個連接阻塞,其它的連接就會連帶阻塞住。解決的方法
一個是多線程,一個連接一個線程,就不會相互影響了。另一個就是 I/O 多路復用,通過 epoll/select 監聽 sockfd, 並返回
就緒狀態的 fd, 這樣就不會因為單個連接影響其它連接了。在用於 TCP 通信的 socket 編程中,connect, accept,read,write
都可能會阻塞,不過我們可以通過將其設置為非阻塞的方式來提高性能和避免單個 sock fd 阻塞住其它的 sock fd.
setnonblocking 函數#
// 一個多系統兼容的setnonblocking
int setnonblocking(int fd) {
if (fd < 0) return -1;
int flags;
/* If they have O_NONBLOCK, use the Posix way to do it */
#if defined(O_NONBLOCK)
/* Fixme: O_NONBLOCK is defined but broken on SunOS 4.1.x and AIX 3.2.5. */
if (-1 == (flags = fcntl(fd, F_GETFL, 0)))
flags = 0;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
#else
/* Otherwise, use the old way of doing it */
flags = 1;
return ioctl(fd, FIOBIO, &flags);
#endif
}
非阻塞 connect#
阻塞的原因#
connect 函數會激發 TCP 的三次握手,向 server 發送一個 SYN ,如果對方在一定的時間內沒有回覆,client 會進行重試,
最長會持續幾十秒。
如何設置非阻塞#
在調用 connect 函數之前,調用 setnonblocking (fd) 即可
非阻塞 connect 的處理#
在 connect 前設置 fd 為非阻塞後,如果 tcp 連接沒有完成,將會先返回 EINPROGRESS 錯誤。在連接發生錯誤情況下 fd 將
即可讀又可寫;在連接完成的情況下,fd 將變得可寫。一般通過 getsockopt () 函數檢查有沒有錯誤返回來判斷連接是否成功建立。
int error;
socklen_t len = sizeof(error);
if(getsockopt(fd,SOL_SOCKET, SO_ERROR, &error, &len) == 0 && error == 0){
printf("connect success\n");
}
else {
printf("connect failed\n");
}
非阻塞 accept#
阻塞的原因#
正常的情況下,使用 epoll/select 後,不需要考慮 accept 的阻塞,因為總是監聽到連接事件後才調用 accept 的。但是有一種特殊的情況
就是:當客戶端發起一個連接,並且這個事件被 epoll/select 監測到,已經放到了待處理的事件列表中,但還沒有馬上處理,這個時候客
戶端中斷了這個連接,伺服器已經將這個連接移除,當進入 accept 的時候,整個程序就會被阻塞住,直到來了新的連接。
處理方法#
- 將對應的 listenfd 設置為非阻塞;
- 在 accept 後忽略 EWOULDBLOCK,ECONNABORTED,EPROTO,EINTR 錯誤
非阻塞 read 和 write#
可讀可寫的判定條件#
可讀
- 有數據可讀
- 收到對端的 FIN 包
- 有新的連接過來
- 待處理錯誤
可寫
- 有用於寫的空間
- 待處理錯誤
非阻塞處理#
調用 setnonblocking (fd) 函數即可,然後用 epoll 監聽對應的可讀可寫事件。監聽到對應事件
後需要在錯誤中忽略 EAGAIN 和 EWOULDBLOCK 錯誤
EPOLL 事件驅動#
以 tunnel read 為例
// 前提 fd&EPOLLIN, 對端fd_peer狀態不清楚
n = read
if ( n < 0) {
//由非阻塞引入的錯誤不需要處理
if(errno != EAGAIN && errno != EWOULDBLOCK) {
//先處理在當前條件下可以馬上處理的
//再根據對端狀態及應用緩存區狀態分類處理
// 关闭fd后,epoll会自动移除对该fd的监听。
close(fd)
//在當前緩存區不為空,對端沒有關閉的情況,需要監聽對端的可寫
if(!fd_pair.closed && fd.buf not empty ){
enable fd_peer EPOLLOUT
//因為fd已經關閉,所以要關閉對端可讀
disable fd EPOLLIN
}
}
}
else if (n == 0) {
if(fd_pair.buf not empty ) {
enable fd EPOLLOUT
}
if(!fd_pair.closed && fd.buf is empty) {
enable fd_pair EPOLLIN
}
}
else {
if(fd.buf is empty) {
if(fd_pair not full && !fd_pair.fd_fin) {
fd_pair EPOLLOUT|EPOLLIN
}
else {
enable fd_pair EPOLLOUT
}
}
if(fd.buf is full) {
if(fd_pair.buf not empty) {
enable fd EPOLLOUT
}
else{
disenable fd EPOLLOUT
}
}
}
關於 tcp 流式套接字特性的處理#
tcp 協議可以保證數據有序的,無誤的傳送給對端(如果出錯就重試或者報錯),但是並不是一次發送多少字節的消息,對端就一次接受多少個字節。在 TCP 基礎上設計應用層協議的時候,要注意這個特性。
一般的解決辦法有兩種,一種是定長消息,即規定一定長度的字節為一個消息,當接受的時候,先將接受字節緩存下來,如果沒有達到該長度,就繼續接受對端的數據。這種方法的靈活性比較差,不合適傳遞複雜的應用層數據
另一種是設計一個數據包頭部,一部分內容用來處理用戶的相關協議,並且在發送的時候加一個長度字段。這樣對端再收到數據後,先解析頭部數據,得到數據的長度,再在收到該長度的消息後,再解析另一個頭部即可。