程序整体框架#
简介#
程序整体是一个单线程的非阻塞的程序,分为 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 基础上设计应用层协议的时候,要注意这个特性。
一般的解决办法有两种,一种是定长消息,即规定一定长度的字节为一个消息,当接受的时候,先将接受字节缓存下来,如果没有达到该长度,就继续接受对端的数据。这种方法的灵活性比较差,不合适传递复杂的应用层数据
另一种是设计一个数据包头部,一部分内容用来处理用户的相关协议,并且在发送的时候加一个长度字段。这样对端再收到数据后,先解析头部数据,得到数据的长度,再在收到该长度的消息后,再解析另一个头部即可。