Chinaunix首页 | 论坛 | 博客
  • 博客访问: 795128
  • 博文数量: 142
  • 博客积分: 3505
  • 博客等级: 中校
  • 技术积分: 1501
  • 用 户 组: 普通用户
  • 注册时间: 2011-07-30 19:30
文章分类

全部博文(142)

文章存档

2012年(33)

2011年(109)

分类: LINUX

2011-10-15 16:13:36

在诸多的网络协议中接触的最多也最紧密的无疑是TCP和UDP,SCTP之前因为项目原因也研究过,不过最终由于方案修改给抛弃了,TCP年代已经很久远,在网上的资料也非常多,而且我感觉它是一种非常复杂的协议,感觉要把编好基于TCP的程序光简单地了解几个socket API是不够的,刚开始接触网络编程的时候自己确实也吃了不少苦头,后来我还专门拿时间出来阅读了一下RFC,再加上长时间的实践总算也对TCP有所了解,把自己的一些经验和教训都总结一下。

首先说一下TCP的状态转移图,这个应该是很重要的,了解TCP运行周期的各种状态才能更好地运用netstat之类的应用程序去对程序进行调试,我这里收藏了一张图,是TCP的状态图,摘自RFC。

连接建立的几个状态没什么可说的,TCP的三次握手众所周知,更重要的是TCP连接中止的几个状态,应该可以说是连接中止需要四次握手吧。

当Client调用close函数主动关闭socket时,连接状态被标记为FIN_WAIT_1,Server在收到FIN之后read函数会返回0,这里server知道Client已经关闭连接,回复ACK,这里client连接状态被标记为FIN_WAIT_2,接下来Server调用close函数关闭连接,这时候Server向client发送FIN,Client收到之后将状态标记为TIME_WAIT,并回复ACK。

TIME_WAIT这个状态存在的意义在于Client回复的ACK未必会被Server收到,可能在传输过程中导致包的丢失,而这里Server未收到ACK之后会重新向Client发送FIN,如果client未将状态标记为TIME_WAIT而是直接标记为CLOSED,则Server发送的FIN会直接收到RST,导致Server端的发送错误,因此Client需要保证有一个TIME_WAIT状态,而这个状态会持续两位的MSL(最大段生命周期),从而保证Server成功发送FIN并发送ACK,为了保证两个数据段传输的最大时间,因此TIME_WAIT持续的时间为两倍的MSL。

Server在收到第一个FIN之后会将状态标记为CLOSE_WAIT,此时是Client主动关闭连接,这里Server也需要调用Close给Client发送FIN(如上所述),之后Server的状态标记为LAST_ACK,表示Server正在等待Client发送的最后一个ACK,当Server收到最后一个ACK便会将连接标记为CLOSED,这时连接结束。TIME_WAIT这个状态和套接字的SO_REUSEADDR选项是有关系的,这个留做后面讨论。

2.TCP连接异常情况

TCP连接异常分为很多种情况,无论是客户端程序还是服务器端程序都需要考虑周全的。

Server在连接的过程中程序崩溃或者CTRL+C中止程序,或者kill掉Server进程。这时会导致Server立即发送一个FIN数据包给Client,Client如果此时正在调用recv函数,则recv函数返回0,表示服务器已关闭连接,如果Client调用send函数继续向Server发送数据,Server在收到后会回复RST,而此时send方法会触发SIGPIPE信号,表示通信管道已断开,在程序中如果对该信号不做处理则会导致程序的崩溃,一般在程序开始时会忽略此信号,则在这种情况下send函数会返回-1,表示发送失败,处理SIGPIPE的代码如下:

  1. struct sigaction sa;
  2. sa.sa_handler = SIG_IGN;
  3. sigaction(SIGPIPE, &sa, 0 );

另外在这种情况下select函数也会立即返回,socket描述符会被设置,而试图从该socket中recv数据,则会返回-1。

另外一种情况是Server系统崩溃或者网络直接异常或断开。这时候Server不可能再给Client发送FIN包,而Client调用send函数后会导致数据包一直重传直接超时后返回-1,而recv函数也会一直阻塞直接超时后返回-1。这种情况就很难判断是Server端进程关闭还是网络异常,这种情况一般会用TCP的KEEP ALIVE机制,每隔一定的时间向对方发送一个只有一字节数据内容的数据包,对端收到后会返回一个ACK,以此来确保连接正常,如果未收到ACK,会尝试重传,直到重试规定次数后可以将与对端的连接标记为断开,send和recv将会返回-1。KEEP ALIVE的使用方法如下:

  1. int tcp_keep_alive(int socketfd)
  2. {
  3.  int keepAlive = 1;
  4.  int keepIdle = 10; /* 开始发送KEEP ALIVE数据包之前经历的时间 */
  5.  int keepInterval = 10; /* KEEP ALIVE数据包之前间隔的时间 */
  6.  int keepCount = 10; /* 重试的最大次数 */
  7.  
  8.  if(setsockopt(socketfd , SOL_SOCKET , SO_KEEPALIVE
  9.     ,(void*)&keepAlive,sizeof(keepAlive)) == -1){
  10.   debug_info("set SO_KEEPALIVE failed\n");
  11.   return -1;
  12.  }
  13.  
  14.  if(setsockopt(socketfd , SOL_TCP , TCP_KEEPIDLE
  15.     ,(void *)&keepIdle,sizeof(keepIdle)) == -1){
  16.   debug_info("set TCP_KEEPIDEL failed\n");
  17.   return -1;
  18.  }
  19.  
  20.  if(setsockopt(socketfd , SOL_TCP , TCP_KEEPINTVL
  21.     ,(void *)&keepInterval,sizeof(keepInterval)) == -1){
  22.   debug_info("set TCP_KEEPINTVL failed\n");
  23.   return -1;
  24.  }
  25.  
  26.  if(setsockopt(socketfd , SOL_TCP , TCP_KEEPCNT
  27.     ,(void *)&keepCount,sizeof(keepCount)) == -1){
  28.   debug_info("set TCP_KEEPCNT failed\n");
  29.   return -1;
  30.  }
  31.  return 1;
  32. }

上面这个函数只针对Linux,昨天有网友告知在Mac OS上TCP_KEEPIDLE ,TCP_KEEPINTVL, TCP_KEEPCNT这些宏将未定义。另外对于这些参数的设置也是需要注意的,很多系统中它们的设置并不是对单个socket描述符起作用的,而是该机器上的所有socket描述符起作用的,所以这个需要注意(这个是从UNP里面看到的)。

3.关于字节顺序
Linux的主机字节顺序是采用little-endian字节顺序,而网络字节顺序是采用big-endian字节顺序,字节顺序转换是必需的。写了一个小程序来检测字节顺序。

  1. #include
  2.  
  3. int main(int argc, char **argv)
  4. {
  5.  short s = 0x0102;
  6.  if((*(unsigned char*)&s) == 2)
  7.   printf("little endian\n");
  8.  else if((*(unsigned char*)&s) == 1)
  9.   printf("big endian\n");
  10.  else
  11.   printf("unknown endian\n");
  12.  
  13.  return 0;
  14. }

3.关于send和recv

写过socket程序的人肯定都会知道send和recv函数并不会总是返回要求发送或读取的字节数,如:

int ret = recv(sk, buf, 2096, 0);

这句话并不总是读取到完整地2096个字节,相反地,大多数情况下都不能将buf读满,recv只能返回当前可以读取到的字节数,如果协议规定本次读取肯定会读取到N个字节,那我一般的做法会写一个这样的函数来确保读取到固定的字节数:

  1. int buf_recv(int sock, void *buf, size_t len, int flags)
  2. {
  3.  int n, ret;
  4.  if(len == 0) return 0;
  5.  for(n=0;n!=len &&(ret = recv(sock, buf+n, len-n, flags)) != -1 &&ret; n += ret);
  6.  return (n!=len)? -1:n;
  7. }

关于这两个函数还有很重要的一点是应该尽可能大地一次发送或接收更多地数据,当然前提是缓冲区中有这些数据的话,原因很简单,当通信链路很好的时候数据可能会填满系统缓冲区,而recv便是从缓冲区中读取数据,这时候一次读取更多地字节就意味着可以少调用几次recv函数,而这些函数通常都是调用了系统调用,需要进行内核态和用户态上下文的切换,也就意味着多调用几次recv会带来额外的开销,之前写的一个代理服务器的程序数据传输速度一直很低,后来修改了recv和send的缓冲区大小后速率提高了近一倍。

4.关于非阻塞模式

一般应用的时候都是使用阻塞式IO,至少我在大多数情况下都用的阻塞式IO,非阻塞很少应用,但存在便我价值,我用到的非阻塞IO的情况一般是用来进行超时connect,首先将socket设为非阻塞模式,connect立即返回-1,此时已向对端发送FIN,而并未来得及收到任何ACK,于是直接返回-1,但并不代表连接失败,errno会被置为EINPROGRESS ,表示连接正在进行中,然后通过select来设置socket可写的超时时间,如果规定时间内可写,且socket并无出错,则表示连接成功,socket出错则表示连接失败,或规定时间内不可写则表示连接超时,简单地写了如下代码:

  1. #include
  2.  
  3. int main(int argc, char *argv[])
  4. {
  5.  int sk;
  6.  int flags;
  7.  int err = 0;
  8.  int ret;
  9.  socklen_t len;
  10.  struct sockaddr_in addr;
  11.  fd_set fd_write;
  12.  struct timeval tv;
  13.  
  14.  if( (sk = socket(AF_INET, SOCK_STREAM, 0)) == -1 ) {
  15.   perror("socket");
  16.   return 1;
  17.  }
  18.  
  19.  if( (flags = fcntl(sk, F_GETFL, 0)) == -1 ){
  20.   perror("fcntl GET flags failed");
  21.   return 1;
  22.  }
  23.  
  24.  if(fcntl(sk, F_SETFL, flags | O_NONBLOCK) == -1) {
  25.   perror("fcntl SET flags failed");
  26.   return 1;
  27.  }
  28.  
  29.  memset(&addr, 0, sizeof(addr));
  30.  addr.sin_family = AF_INET;
  31.  addr.sin_addr.s_addr = inet_addr("59.64.129.169");
  32.  addr.sin_port = htons(808);
  33.  
  34.  if(connect(sk, (struct sockaddr*)&addr, sizeof(addr)) == -1 ) {
  35.   if(errno != EINPROGRESS) {
  36.    perror("connect");
  37.    return 1;
  38.   }
  39.   FD_ZERO(&fd_write);
  40.   FD_SET(sk, &fd_write);
  41.   tv.tv_sec = 5;
  42.   tv.tv_usec = 0;
  43.  
  44.   ret = select(sk + 1, (fd_set*)0, &fd_write, (fd_set*)0, &tv);
  45.  
  46.   if(ret > 0){
  47.  
  48.    if(FD_ISSET(sk, &fd_write)) {
  49.     len = sizeof(int);
  50.  
  51.     if(getsockopt(sk, SOL_SOCKET, SO_ERROR, &err, &len) == 0) {
  52.      if(err == 0) { printf("connect success\n"); return 0; }
  53.      else { fprintf(stderr, "connect:%s\n", strerror(err)); return 1; }
  54.     }else{
  55.      fprintf(stderr, "getsockopt:%s\n", strerror(err));
  56.      return 1;
  57.     }
  58.    }else{
  59.     fprintf(stderr, "connect(FD_ISSET) failed\n");
  60.     return 1;
  61.    }
  62.  
  63.   }else if(ret == 0) {
  64.    fprintf(stderr, "connect timeout\n");
  65.    return 1;
  66.   }else {
  67.    fprintf(stderr, "connect(select):%s\n", strerror(errno));
  68.    return 1;
  69.   }
  70.  }else{
  71.   fprintf(stderr, "connect:%s\n", strerror(errno));
  72.   return 1;
  73.  }
  74.  return 0;
  75. }

5.关于select多路复用

select是网络编程中很常用的函数,用来进行IO多路复用,但之前我一直忽略了一个问题,当select返回时会将本次检查中不可用的描述符(如不可读或不可写)的描述符从描述符集中删除,只保留当前可用的描述符,在对多个socketfd进行利用的时候需要注意,每次循环select之前都需要在select之前用FD_SET重新设置描述符,否则之后便只能返回第一次可读的描述符了。

6.关于UDP广播

UDP广播这个也简单说一下,首先255.255.255.255这个地址是不能被路由的,只能被本物理网络的数据包接收。UDP广播之前需要给socket设置SO_BROADCAST选项:

int brodopt = 1; 
setsockopt(cp_usock, SOL_SOCKET, SO_BROADCAST, &brodopt, sizeof(brodopt));

UDP广播需要注意的一点是广播接收时接收端bind的本地地址的问题,接收端必须绑定INADDR_ANY这个地址才可以接收广播包,如果是绑定的某个特定的地址则无法接收广播包。

OK,简单说这么几条,都是我编程时候遇到的经验总结,以后再遇到什么问题再接着补充。

阅读(1106) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~