客户在0时刻发出请求,我们假设RTT为8个时间单位。其应答在时刻4发出并在时刻7接收到。我们还假设没有服务器处理时间而且请求大小与应答大小相同。
既然一个分组从管道的一端发出到达管道的另一端存在延迟,而管道是全双工的,就本例而言,我们仅仅使用了管道容量的1/8,这种停等方式对于交互式输入合适的,然而由于我们的客户是从标准输入读并往标准输出写,在Unix的Shell环境下重定向标准输入和标准输出又是轻而易举之事,我们可以很容易地以批量方式运行客户。当我们把标准输入和标准输出重定向到文件来运行新的客户程序时,却发现输出文件总是小于输入文件(而对回射服务器而言,它们理应相等)。
为了搞清楚到底发生了什么,我们应该意识到在批量方式下,客户能够以网络可以接受的最快速度持续发送请求,服务器以相同的速度处理它们并发回应答。这就导致时刻7时管道充满,如图1-10所示。
图1-10 填充客户与服务器之间的管道,批量方式
为了搞清楚上面客户处理函数存在的问题,我们假设输入文件只有9行。最后一行在时刻8发出,如图1-10所示。写完这个请求后,我们并不能立即关闭连接,因为管道中还有其他的请求和应答。问题的起因在于我们对标准输入中的EOF的处理:客户端处理函数就此返回到main函数,而main函数随后终止。然而在批量方式下,标准输入中的EOF并不意味着我们同时完成了从套接字的读入:可能仍有请求在去往服务器的路上,或者仍有应答在返回客户的路上。
我们需要的是一种关闭TCP连接其中一半的方法。也就是说,我们想给服务器发送一个FIN,告诉我们已经完成了数据发送,但是仍然保持套接字描述符打开以便读取。这将由shutdown函数来完成。
一般来说,为了提升性能而引入缓冲机制增加了网络应用程序的复杂性。混合使用stdio和select被认为是非常容易犯错的,在这样做时必须极其小心。
1.6 shutdown函数
终止网络连接通常的方法是调用close函数,不过close有两个限制,却可以使用shutdown来避免。
(1)close把描述符的引用计数减1,仅在该计数变为0时才关闭套接字。使用shutdown可以不管引用计数就激发TCP的正常连接终止序列。
(2)close终止读和写两个方向的数据传送。既然TCP连接是全双工的,有时候我们需要告知对端我们已经完成了数据发送,即使对端任由数据要发送给我们。这就是上面客户端处理函数遇到的批量输入时的情况。图1-11展示了这样的情况下典型的函数调用。
图1-11 调用shutdown关闭一半TCP连接
/* The following constants should be used for the second parameter of
`shutdown'. */
enum
{
SHUT_RD = 0, /* No more receptions. */
#define SHUT_RD SHUT_RD
SHUT_WR, /* No more transmissions. */
#define SHUT_WR SHUT_WR
SHUT_RDWR /* No more receptions or transmissions. */
#define SHUT_RDWR SHUT_RDWR
};
/* Shut down all or part of the connection open on socket FD.
HOW determines what to shut down:
SHUT_RD = No more receptions;
SHUT_WR = No more transmissions;
SHUT_RDWR = No more receptions or transmissions.
Returns 0 on success, -1 for errors. */
extern int shutdown (int __fd, int __how) __THROW;
该函数的行为依赖于__how参数的值。
SHUT_RD 关闭连接的读这一半——套接字中不再有数据可接收,而且套接字接收缓冲区中的现有数
据都被丢弃。进程不能再对这样的套接字调用任何读函数。对一个TCP套接字这样调
用shutdown函数后,由该套接字接收的来自对端的任何数据都被确认,然后悄然丢弃。
SHUT_WR 关闭连接的写这一半——对于TCP套接字,这称为半关闭(half-close)。当前留在套
接字发送缓冲区中的数据将被发送掉,后跟TCP正常连接终止序列。我们已经说过,不管套
接字描述符的引用计数是否等于0,这样的写半部关闭照样执行。进程不能再对这样的套接
字调用任何写函数。
SHUT_RDWR 连接的读半部和写半部都关闭——这与调用shutdown两次等效:第一次调用指定SHUT_RD,
第二次调用指定SHUT_RW。
1.7 客户端处理函数(再修订版)
下面给出了客户端处理函数的改进(且正确)版本。它使用了select和shutdown,其中前者只要服务器关闭它那一端的连接就会通知我们,后者允许我们正确地处理批量输入。这个版本还废弃了以文本行为中心的代码,该而针对缓冲区操作,从而消除了前面提到的复杂性问题。
// 完成剩余部分的客户处理工作。
// char buf[ MAX_MESG_SIZE ];
char sendbuff[ MAX_MESG_SIZE ];
char recvbuf[ MAX_MESG_SIZE ];
int nfds; // 描述符总数
int stdineof; // 这是一个初始化为0的新标志。只要该标志为0,每次在主循环中我们总是select标准输入的可读性。
fd_set rset; // 描述符集
int n;
stdineof = 0;
FD_ZERO( &rset );
for( ; ; )
{
if( stdineof == 0 )
FD_SET( fileno( stdin ), &rset );
FD_SET( sockfd, &rset );
nfds = max( fileno( stdin ), sockfd ) + 1;
select( nfds, &rset, NULL, NULL, NULL );
// 当我们在套接字上读到EOF时,如果我们已在标准输入上遇到EOF,那就正常的终止,于是函数返回;
// 但是如果我们在标准输入上没有遇到EOF,那么服务器进程已过早终止。我们改用recv和send对缓冲区而不是
// 文本行进行操作,使得select能够如期地工作。
if( FD_ISSET( sockfd, &rset ) ) // socket is readable
{
if( ( n = read( sockfd, recvbuf, MAX_MESG_SIZE ) ) == 0 )
{
if( stdineof == 1 )
return 0; // normal termination
else
cout << " mimiasd:server terminated prematurely " << endl;
exit( 1 );
}
recvbuf[ n ] = 0;
write( fileno( stdout ), recvbuf, n);
}
//当我们在标准输入上碰到EOF时,我们把新标志stdineof置为1,并把第二个参数指定为SHUT_WR来调用shutdown以发送FIN。
// 这儿我们也改用read和write对缓冲区而不是文本行进行操作。
if( FD_ISSET( fileno( stdin ), &rset ) ) // input is readable
{
if( ( n = read( fileno( stdin ), sendbuff, MAX_MESG_SIZE ) ) == 0 )
{
stdineof = 1;
shutdown( sockfd, SHUT_WR ); // send FIN
FD_CLR( fileno( stdin ), &rset );
continue;
}
write( sockfd, sendbuff, strlen( sendbuff ) );
}
}
1.8 TCP回射服务器程序(修订版)
把前面TCP回射服务器程序,把它重写成使用select来处理人一个客户的单进程程序,而不是为每个客户派生一个子进程。图1-12给出了第一个客户建立连接前服务器的状态。
图1-12 第一个客户建立连接前的服务器状态
服务器有单个监听描述符,我们用一个圆点来表示。
服务器只维护一个读描述符集,如图1-13所示。假设服务器是在前台启动的,那么描述符0、1和2将被设置为标准输入、标准输出和标准错误输出。可见监听套接字的第一个可用描述符是3。图1-13还展示了一个名为client的整型数组,它含有每个客户的已连接套接字描述符。该数组的所有元素都被初始化为-1。
图1-13 仅有一个监听套接字的TCP服务器的数据结构
描述符集中唯一的非0项是表示监听套接字的项,因此select的第一个参数将为4。
当第一个客户与服务器建立连接时,监听描述符变为可读,我们的服务器于是调用accept。在本例假设下,由accept返回的新的已连接描述符将是4。图1-14展示了从客户到服务器的连接。
图1-14 第一个客户建立连接后的TCP服务器
从现在起,我们的服务器必须在其client数组中记住每个新的已连接描述符,并把它加到描述符集中去。图1-15展示了这样更新后的数据结构。
图1-15 第一个客户连接建立后的数据结构
稍后,第二个客户与服务器建立连接,图1-16展示了这种情形。
图1-16 第二个客户建立连接后的数据结构
新的已连接描述符(建设是5)必须被记住,从而给出如图1-17所示的数据结构。
图1-17 第二个客户连接建立后的数据结构
我们接着假设第一个客户终止它的连接。该客户的TCP发送一个FIN,使得服务器中的描述符4变为可读。当服务器读这个已连接套接字时,read将返回0。我们于是关闭该套接字并相应地更新数据结构:把client[0]的值置为-1,把描述符集中描述符4的为置为0,如图1-18所示。注意,maxfd的值没有改变。
图1-18 第一个客户终止连接后的数据结构
总之,当有客户到达时,我们在client数组中的第一个可用项(即值为-1的第一个项)中记录其已连接套接字的描述符。我们还把这个已连接描述符加到读描述符集中。变量maxi是client数组当前使用项的最大下标,而变量maxfd(加1之后)是select函数第一个参数的当前值。对于本服务器所能处理的最大客户数目的限制是以下两个值中的较小值:FD_SETSIZE和内核允许本进程打开的最大描述符数。
下面给出了这个版本服务器程序的前半部分。
int main()
{
int i, maxi, maxfd, listenfd, sockfd, connectfd;
int nready, client[ FD_SETSIZE ];
ssize_t n;
fd_set rset, allset;
char buf[ MAX_MESG_SIZE ];
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
bzero( &servaddr, sizeof( servaddr ) );
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons( SERV_PORT );
servaddr.sin_addr.s_addr = htonl( INADDR_ANY );
// 创建一个TCP套接字
if( ( listenfd = socket( AF_INET, SOCK_STREAM, 0 ) ) < 0 )
{
printf( " socket error!\n " );
return -1;
}
// 在待绑定到TCP套接字的网际套接字地址结构中填入通配地址(INADDR_ANY)和服务器的众所周知端口(SERV_PORT,
// 这里定义为5566)。绑定通配地址是在告知系统:要是系统是多宿主机,我们将接受目的地址为任何本地接口的连接。
// 我们选择TCP端口号应该比1023大(我们不需要一个保留端口),比5000大(以免与许多源自Berkeley的实现分配临
// 时端口的范围冲突),比49152小(以免与临时端口号的“正确”范围冲突),而且不应该与任何已注册的端口冲突。
// listen把该套接字转换成一个监听套接字。
if( ( bind( listenfd, ( struct sockaddr* ) &servaddr, sizeof(servaddr) ) ) < 0 )
{
printf( " bind error!\n " );
return -1;
}
if( listen( listenfd, 5 ) < 0 )
{
printf( " listen error!\n " );
return -1;
}
maxfd = listenfd; // initialize
maxi = -1; // index into client[] array
for( i = 0; i < FD_SETSIZE; i++ )
client[ i ] = -1; // -1 indicates available entry
FD_ZERO( &allset );
FD_SET( listenfd, &allset );
signal( SIGCHLD, sig_chld );
main函数的后半部分如下。
for( ; ; )
{
rset = allset; // structure assignment
nready = select( maxfd + 1, &rset, NULL, NULL, NULL );
if( FD_ISSET( listenfd, &rset ) )
{
clilen = sizeof( cliaddr );
connectfd = accept( listenfd, ( struct sockaddr* ) &cliaddr, &clilen );
for( i = 0; i < FD_SETSIZE; i++ )
if( client[ i ] < 0 )
{
client[ i ] = connectfd; // save descriptor
break;
}
if( i == FD_SETSIZE )
{
printf( " too many clients " );
exit( 1 );
}
FD_SET( connectfd, &allset ); // add new descriptor to set
if( connectfd > maxfd )
maxfd = connectfd; // for select
if( i > maxi )
maxi = i; // max index in client[] array
if( --nready <= 0 )
continue; // no more readable descriptors
}
for( i = 0; i <= maxi; i++ ) // check all clients for data
{
if( ( sockfd = client[ i ] ) < 0 )
continue;
if( FD_ISSET( sockfd, &rset ) )
{
if( ( n = read( sockfd, buf, MAX_MESG_SIZE ) ) == 0 )
{
// connection closed by client
close( sockfd );
FD_CLR( sockfd, &allset );
client[ i ] = -1;
}
else
writen( sockfd, buf, n );
if( --nready <= 0 )
break; // no more readable descriptors
}
}
}
拒绝服务型攻击
当一个服务器在处理多个客户时,它绝对不能阻塞于只与单个客户相关的某个函数调用。否则可能导致服务器被挂起,拒绝为所有其他客户提供服务。这就是所谓的拒绝服务(denial of service)型攻击。它就是针对服务器做些动作,导致服务器不再能为其他合法客户提供服务。可能解决的办法包括:(a)使用非阻塞式I/O;(b)让每个客户单独的控制线程提供服务(例如创建一个子进程或一个线程来服务每个客户);(c)对I/O操作设置一个超时。
1.9 pselect函数
pselect函数是POSIX发明的,如今许多Unix变种支持它。
#include <sys/select.h>
/* Same as above only that the TIMEOUT value is given with higher
resolution and a sigmask which is been set temporarily. This version
should be used.
This function is a cancellation point and therefore not marked with
__THROW. */
extern int pselect (int __nfds, fd_set *__restrict __readfds,
fd_set *__restrict __writefds,
fd_set *__restrict __exceptfds,
const struct timespec *__restrict __timeout,
const __sigset_t *__restrict __sigmask);
pselect相对于通常的select有两个变化。
(1)pselect使用timespec结构,而不使用timeval结构。timespec结构是POSIX的有一个发明。
/* POSIX.1b structure for a time value. This is like a `struct timeval' but
has nanoseconds instead of microseconds. */
struct timespec
{
__time_t tv_sec; /* Seconds. */
__syscall_slong_t tv_nsec; /* Nanoseconds. */
};
这两个结构的区别在于第二个成员:新结构的该成员tv_nsec指定纳秒数,而旧结构的该成员tv_usec指定微秒数。
(2)pselect函数增加了第六个参数:一个指向信号掩码的指针。该参数允许程序先禁止递交某些信号,再测由这些当前被禁止信号的信号处理函数设置的全局变量,然后调用pselect,告诉它重新设置信号掩码。
关于第二点,看下面的例子。这个程序的SIGINT信号处理函数仅仅设置全局变量intr_flag并返回。如果我们的进程阻塞于select调用,那么信号处理函数的返回将导致select返回EINTR错误。然而调用select时,代码看起来大体如下:
if( intr_flag )
handle_intr(); // handle the signal
if( ( nready = select( ... ) ) < 0 )
{
if( errno == EINTR )
{
if( intr_flag )
handle_intr();
}
}
问题是,在测intr_flag和调用select之间如果有信号发生,那么若select永远阻塞,该信号将丢失。有了pselect之后,我们可以下方式可靠地编写这个例子的代码:
sigset_t newmask, oldmask, zermask;
sigemptyset( &zeromask );
sigemptyset( &newmask );
sigaddset( &newmask, SIGINT );
sigpromask( SIG_BLOCK, &newmask, &oldmask ) // block SIGINT
if( intr_flag )
handle_intr(); // handle the signal
if( ( nready = pselect( ..., &zeromask ) ) < 0 )
{
if( errno == EINTR )
{
if( intr_flag )
handle_intr();
}
}
在测intr_flag变量之前,我们阻塞SIGINT。当pselect被调用时,它先以空集(即zeromask)替代进程的信号掩码,再检查描述符,并可能进入睡眠。然而当pselect函数返回时,进程的信号掩码又被重置为调用pselect之前的值(即SIGINT被阻塞)。