网络编程实战

本文是学习极客时间课程《网络编程实战》的笔记。

基础篇

C-S编程模型

网络编程中,会区分客户端和服务端,二者的编程模型是不同的,通常来说客户端会主动发起请求,服务端则响应请求
在这里插入图片描述

  • 服务器需要一开始就监听在一个众所周知的端口上,等待客户端发送请求,一旦有客户端连接建立,服务端就会消耗一定的计算机资源为它服务。
  • 客户端向服务器的监听端口发起连接请求,连接建立之后,通过连接通路和服务器端进行通信。
  • 无论是客户端还是服务端,它们运行的单位都是进程(process),而不是机器。
    TCP/IP协议中,IP地址用来标识网络中的一台主机,端口号则用来指定运行监听在该端口的进程,一个TCP连接是由客户端-服务器端的IP地址和端口号构成的四元组唯一确定的:
(clientaddr:clientport, serveraddr: serverport)

有两种截然不同的传输层协议:

  • 面向连接的TCPTCP是一个面向连接的流式协议,通过诸如连接管理、拥塞控制、数据流和窗口、超时和重传等设计,提供了高质量的端到端通信。
  • 无连接的UDPUDP是无连接的数据报协议,不保证可靠传输。

参考资料:
极客时间—网络编程实战02


socket

客户端和服务器的通信流程如下:
在这里插入图片描述
其中连接建立、读写等都要通过socket来完成,socket是TCP/IP网络编程的接口

  • 客户端发起连接请求前,服务器需要创建socket,使用bind函数绑定在一个众所周知的IP地址和端口上,紧接着使用listen监听在该端口上,最后阻塞在accept处等待客户端发起连接。
  • 客户端初始化socket后通过connect向服务端的地址和端口发起连接请求,这个过程包括TCP三次握手
  • 连接建立成功后,数据的传输是双向的,客户端可以给服务器发送信息,服务器也可以给客户端发信息。
  • 需要断开连接时,通过close单向断开,每次单向断开都会执行两次挥手的动作,两端都执行close后,双向断开就完成了。
    在这里插入图片描述
    socket的通用地址格式如下:
/* POSIX.1g 规范规定了地址族为2字节的值. */
typedef unsigned short int sa_family_t;
/* 描述通用套接字地址 */
struct sockaddr{ 
	sa_family_t sa_family; /* 地址族. 16-bit*/ 
	char sa_data[14]; /* 具体的地址值 112-bit */ 
};
  • sa_family
    • AF_LOCAL:本地套接字
    • AF_INET:使用IPv4格式的ip地址
    • AF_INET6:使用IPv6格式的ip地址
  • IPv4的地址结构如下:
struct in_addr
{
    in_addr_t s_addr;
};  

// sizeof(struct sockaddr) == sizeof(struct sockaddr_in)
struct sockaddr_in
{
    sa_family_t sin_family;		/* 地址族协议: AF_INET */
    in_port_t sin_port;         /* 端口, 2字节-> 大端  */
    struct in_addr sin_addr;    /* IP地址, 4字节 -> 大端  */
    /* 填充 8字节 */
    unsigned char sin_zero[sizeof (struct sockaddr) - sizeof(sin_family) -
               sizeof (in_port_t) - sizeof (struct in_addr)];
}; 
  • 本地socket的地址结构如下,因为本地socket本质上是访问本地的文件系统,所以不需要端口号:
struct sockaddr_un { 
	unsigned short sun_family; /* 固定为 AF_LOCAL */ 
	char sun_path[108]; /* 路径名 */
};

总结一下,几种套接字地址格式的比较如下:
在这里插入图片描述
可以看到几种地址结构体的长度不同,可使用时却都是传入struct sockaddr*,因为长度不同,实际上使用时还要传入结构体的大小。

参考资料:
极客时间—网络编程实战03
套接字-Socket
三次握手、四次挥手


使用socket建立连接

服务器和客户端建立连接的流程如下:
在这里插入图片描述

  • 服务器端通信流程:
    1. 创建用于监听的套接字,这个套接字是一个文件描述符:
    int lfd = socket();
    
    1. 将监听文件描述符和本地IP地址和端口绑定:
    bind();
    
    1. 设置监听连接事件:
    listen();
    
    1. 阻塞等待接受客户端的连接请求, 建立新的连接, 得到一个新的通信文件描述符(通信的):
    int cfd = accept();
    
    1. 通信,读写操作默认阻塞:
    // 接收数据
    read(); / recv();
    // 发送数据
    write(); / send();
    
    1. 断开连接, 关闭套接字:
    close();
    
  • 客户端通信流程:
    1. 创建一个通信的套接字:
    int cfd = socket();
    
    1. 连接服务器,需要知道服务器的IP地址和监听端口号:
    connect();
    
    1. 通信:
    // 接收数据
    read(); / recv();
    // 发送数据
    write(); / send();
    
    1. 断开连接:
    close();
    

上面看到客户端没有用bind绑定某个端口号,这是因为系统会自动选择空闲端口绑定,当然也可以手动bind绑定,但是这样比较容易造成端口冲突,最好还是让系统自动绑定。
客户端和服务器建立连接的过程中,相应api对应握手过程如下:
在这里插入图片描述
服务器端监听socket维护半连接队列全连接队列。服务器端连接处于SYN_RCVD状态时,连接在半连接队列中;服务器端连接处于ESTABLISHED状态时,连接被添加到全连接队列,此时accept从全连接队列取出连接,解除阻塞并返回。

参考资料:
极客时间—网络编程实战04
套接字-Socket
最基本的Socket模型


TCP Socket

发送数据

ssize_t write (int socketfd, const void *buffer, size_t size)
ssize_t send (int socketfd, const void *buffer, size_t size, int flags)
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags)
  • write用于普通的写入操作
  • send带有一个flag参数,想指定选项,发送带外数据时,需要使用第二个带flag的函数
  • sendmsg用于指定多重缓冲区的发送

上述写入函数用于写入socket时与写文件有所不同:

  • 对于普通文件描述符而言,通过write函数写入文件时,写入的字节流大小通常和输入参数size的值相同,否则表示出错。
  • 对于套接字文件描述符,在套接字文件描述符上调用write写入的字节数有可能比请求的数量少。

接收数据

ssize_t read (int socketfd, void *buffer, size_t size)

read函数要求从套接字描述字 socketfd读取最多多少个字节(size),并将结果存储到 buffer 中。返回值告诉我们实际读取的字节数目,也有一些特殊情况,如果返回值为 0,表示 EOF(end-of-file),这在网络中表示对端发送了 FIN 包,要处理断连的情况;如果返回值为 -1,表示出错。

要注意,上面write写入成功后不代表对方已经接收到了数据,每一个socket连接都有一个写缓冲区和读缓冲区write成功只是说明数据已经写到了socket的缓冲区中。
在这里插入图片描述
服务器端Demo代码如下(为了代码简洁没有处理错误情况,实际上要处理):

// server.c
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

int main() {
  // 1. 创建监听的套接字
  int lfd = socket(AF_INET, SOCK_STREAM, 0);

  // 2. 将socket()返回值和本地的IP端口绑定到一起
  struct sockaddr_in addr;
  addr.sin_family = AF_INET;
  addr.sin_port = htons(10000);
  addr.sin_addr.s_addr = htonl(INADDR_ANY);
  int ret = bind(lfd, (struct sockaddr *)&addr, sizeof(addr));

  // 3. 设置监听
  ret = listen(lfd, 128);

  // 4. 阻塞等待并接受客户端连接
  struct sockaddr_in cliaddr;
  socklen_t clilen = sizeof(cliaddr);
  int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &clilen);

  // 5. 和客户端通信
  while (1) {
    // 接收数据
    char buf[1024];
    memset(buf, 0, sizeof(buf));
    int len = read(cfd, buf, sizeof(buf));
    if (len > 0) {
      printf("客户端say: %s\n", buf);
      write(cfd, buf, len);
    } else if (len == 0) {
      printf("客户端断开了连接...\n");
      break;
    } else {
      perror("read");
      break;
    }
  }

  close(cfd);
  close(lfd);

  return 0;
}

客户端demo代码如下:

// client.c
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main() {
  // 1. 创建通信的套接字
  int fd = socket(AF_INET, SOCK_STREAM, 0);
  if (fd == -1) {
    perror("socket");
    exit(0);
  }

  // 2. 连接服务器
  struct sockaddr_in addr;
  addr.sin_family = AF_INET;
  addr.sin_port = htons(10000);
  inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr);

  int ret = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
  if (ret == -1) {
    perror("connect");
    exit(0);
  }

  // 3. 和服务器端通信
  int number = 0;
  while (1) {
    // 发送数据
    char buf[1024];
    sprintf(buf, "你好, 服务器...%d\n", number++);
    write(fd, buf, strlen(buf) + 1);

    // 接收数据
    memset(buf, 0, sizeof(buf));
    int len = read(fd, buf, sizeof(buf));
    if (len > 0) {
      printf("服务器say: %s\n", buf);
    } else if (len == 0) {
      printf("服务器断开了连接...\n");
      break;
    } else {
      perror("read");
      break;
    }
    sleep(1); // 每隔1s发送一条数据
  }

  close(fd);

  return 0;
}

参考资料:
极客时间—网络编程实战05
套接字-Socket


UDP Socket

UDP是一个无连接、不可靠的数据报协议。

  • UPD通信不需要建立连接,因此不需要connect
  • UDP通信过程中,每次都需要指定数据接收端的IP和端口,也就是说UDP报文每次都会获取对端的信息,报文与报文之间没有上下文
  • UDP没有重传和确认,没有有序控制,也没有拥塞控制。可以简单的理解为,在IP报文的基础上,UDP增加的能力有限。

UPD的通信流程如下:
在这里插入图片描述
UDP使用recvfromsendto来接收和发送报文:

#include <sys/socket.h>

ssize_t recvfrom(int sockfd, void *buff, size_t nbytes, int flags, 
          struct sockaddr *from, socklen_t *addrlen); 

ssize_t sendto(int sockfd, const void *buff, size_t nbytes, int flags,
                const struct sockaddr *to, socklen_t addrlen); 

可以看到recvfromsendto都需要指定上下文。

UDP服务器端demo代码如下:

#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

int main() {
  // 1. 创建通信的套接字
  int fd = socket(AF_INET, SOCK_DGRAM, 0);

  // 2. 通信的套接字和本地的IP与端口绑定
  struct sockaddr_in addr;
  addr.sin_family = AF_INET;
  addr.sin_port = htons(9999);              // 大端
  addr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
  int ret = bind(fd, (struct sockaddr *)&addr, sizeof(addr));

  char buf[1024];
  char ipbuf[64];
  struct sockaddr_in cliaddr;
  socklen_t len = sizeof(cliaddr);
  // 3. 通信
  while (1) {
    // 接收数据
    memset(buf, 0, sizeof(buf));
    int rlen =
        recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)&cliaddr, &len);
    printf("客户端的IP地址: %s, 端口: %d\n",
           inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, ipbuf, sizeof(ipbuf)),
           ntohs(cliaddr.sin_port));
    printf("客户端say: %s\n", buf);

    // 回复数据
    // 数据回复给了发送数据的客户端
    sendto(fd, buf, rlen, 0, (struct sockaddr *)&cliaddr, sizeof(cliaddr));
  }

  close(fd);

  return 0;
}

客户端demo代码如下:

#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main() {
  // 1. 创建通信的套接字
  int fd = socket(AF_INET, SOCK_DGRAM, 0);

  // 初始化服务器地址信息
  struct sockaddr_in seraddr;
  seraddr.sin_family = AF_INET;
  seraddr.sin_port = htons(9999); // 大端
  inet_pton(AF_INET, "127.0.0.1", &seraddr.sin_addr.s_addr);

  char buf[1024];
  char ipbuf[64];
  struct sockaddr_in cliaddr;
  int len = sizeof(cliaddr);
  int num = 0;
  // 2. 通信
  while (1) {
    sprintf(buf, "hello, udp %d....\n", num++);
    // 发送数据, 数据发送给了服务器
    sendto(fd, buf, strlen(buf) + 1, 0, (struct sockaddr *)&seraddr,
           sizeof(seraddr));

    // 接收数据
    memset(buf, 0, sizeof(buf));
    recvfrom(fd, buf, sizeof(buf), 0, NULL, NULL);
    printf("服务器say: %s\n", buf);
    sleep(1);
  }

  close(fd);

  return 0;
}

参考资料:
极客时间—网络编程实战06
基于UDP的套接字通信


本地Socket

本地Socket是本地进程间通信的一种方法。与TCP/UDP Socket即使在本机地址通信,也要走协议栈。而本地套接字用一个sock文件作为本机地址,收发数据时只是将应用层数据从一个进程拷贝到另一个进程。
本地socket也区分UDPTCP协议,这里使用TCP写demo。
服务端的demo如下:

#include <ctype.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>

#define MAXLINE 80

char *socket_path = "server.socket";

int main(void) {
  struct sockaddr_un serun, cliun;
  socklen_t cliun_len;
  int listenfd, connfd, size;
  char buf[MAXLINE];
  int i, n;

  listenfd = socket(AF_UNIX, SOCK_STREAM, 0);

  memset(&serun, 0, sizeof(serun));
  serun.sun_family = AF_UNIX;
  strcpy(serun.sun_path, socket_path);
  size = offsetof(struct sockaddr_un, sun_path) + strlen(serun.sun_path);
  unlink(socket_path);
  int ret = bind(listenfd, (struct sockaddr *)&serun, size);
  printf("UNIX domain socket bound\n");

  ret = listen(listenfd, 20);
  printf("Accepting connections ...\n");

  while (1) {
    cliun_len = sizeof(cliun);
    connfd = accept(listenfd, (struct sockaddr *)&cliun, &cliun_len);

    while (1) {
      n = read(connfd, buf, sizeof(buf));
      if (n < 0) {
        perror("read error");
        break;
      } else if (n == 0) {
        printf("EOF\n");
        break;
      }

      printf("received: %s", buf);

      for (i = 0; i < n; i++) {
        buf[i] = toupper(buf[i]);
      }
      write(connfd, buf, n);
    }
    close(connfd);
  }

  close(listenfd);
  return 0;
}

注意bind绑定本地地址前要先unlink一下,删除掉旧的sock文件
客户端demo如下:

#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>

#define MAXLINE 80

char *client_path = "client.socket";
char *server_path = "server.socket";

int main() {
  struct sockaddr_un cliun, serun;
  int len;
  char buf[100];
  int sockfd, n;

  sockfd = socket(AF_UNIX, SOCK_STREAM, 0);

  // 一般显式调用bind函数,以便服务器区分不同客户端
  memset(&cliun, 0, sizeof(cliun));
  cliun.sun_family = AF_UNIX;
  strcpy(cliun.sun_path, client_path);
  len = offsetof(struct sockaddr_un, sun_path) + strlen(cliun.sun_path);
  unlink(cliun.sun_path);
  int ret = bind(sockfd, (struct sockaddr *)&cliun, len);

  memset(&serun, 0, sizeof(serun));
  serun.sun_family = AF_UNIX;
  strcpy(serun.sun_path, server_path);
  len = offsetof(struct sockaddr_un, sun_path) + strlen(serun.sun_path);
  ret = connect(sockfd, (struct sockaddr *)&serun, len);

  while (fgets(buf, MAXLINE, stdin) != NULL) {
    write(sockfd, buf, strlen(buf));
    n = read(sockfd, buf, MAXLINE);
    if (n < 0) {
      printf("the other side has been closed.\n");
    } else {
      write(STDOUT_FILENO, buf, n);
    }
  }

  close(sockfd);
  return 0;
}

参考资料:
极客时间—网络编程实战07
Unix domain socket 简介


网络相关工具

  • ping:基于ICMP协议实现,用于测试网络的连通性。
  • ifconfig:显示当前系统中所有网络设备的信息。
  • netstat/ss:了解当前的网络连接状况。
  • lsof:找出在指定IP地址或者端口上打开套接字的进程。
  • tcpdump:抓包工具。

参考资料:
极客时间—网络编程实战08


提高篇

TIME_WAIT

TCP连接关闭时,主动关闭的一方会进入TIME_WAIT状态:
在这里插入图片描述
Linux系统在TIME_WAIT状态下停留的时间为固定的60秒

之所以需要TIME_WAIT状态是因为:

  1. 防止本次连接中的数据,被之后有相同四元组标记的连接错误接收到。
  2. 保证被动关闭连接方能够正确接收到最后的ACK,从而正确关闭。

TIME_WAIT过多的危害:

  1. 占用系统资源,比如文件描述符、内存资源、CPU资源和线程资源等等。
  2. 占用端口资源,端口号是有限的。

优化TIME_WAIT的方法:

  1. 打开 net.ipv4.tcp_tw_reusenet.ipv4.tcp_timestamps 选项:开启这两个参数后则可以复用处于TIME_WAIT状态的socket为新连接所用。tcp_tw_reuse功能只适用于客户端(连接发起方),开启了该功能后,在调用connect()函数时,内核会随机找一个TIME_WAIT状态超过1秒的连接给新的连接复用。
  2. 设置net.ipv4.tcp_max_tw_buckets:这个值默认为 18000,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将所有的 TIME_WAIT 连接状态重置,并且只打印出警告信息。这个方法比较暴力,不推荐使用。
  3. socket设置SO_LINGER选项:linger的英文意思为停留,设置套接字的该选项,来控制closeshutdown关闭连接时的行为。
    int setsockopt(int sockfd, int level, int optname, const void *optval,
            socklen_t optlen);
    struct linger {
     int  l_onoff;    /* 0=off, nonzero=on */
     int  l_linger;    /* linger time, POSIX specifies units as seconds */
    }
    
    • l_onoff0,那么关闭该选项。l_linger的值被忽略,这对应了默认行为,closeshutdown立即返回。如果当前套接字发送缓冲区中有数据残留,系统会尝试将数据发送出去。
    • l_onoff0l_linger0,那么调用 close 后,会立该发送一个 RST 标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接关闭。这种关闭的方式称为“强行关闭”。 在这种情况下,排队数据不会被发送,被动关闭方也不知道对端已经彻底断开。只有当被动关闭方正阻塞在recv()调用上时,接受到 RST 时,会立刻得到一个“connet reset by peer”的异常。
    • l_onoff为非 0, 且l_linger的值也非 0,那么调用 close 后,调用 close 的线程就将阻塞,直到数据被发送出去,或者设置的l_linger计时时间到。

参考资料:
极客时间—网络编程实战10
如何优化TIME_WAIT


优雅地关闭连接

关闭连接可以使用closeshutdown
close关闭连接时:

  • 在输入方向,系统内核将套接字设置为不可读,任何读操作都会返回异常。
  • 在输出方向,系统内核尝试将发送缓冲区的数据发送给对端,并最后向对端发送一个FIN报文,接下来如果再对该套接字进行写操作都会返回异常。

向一个已经关闭的连接继续发送报文会接收到一个RST报文。
shutdown关闭连接时,可以指定连接关闭的方式:

  • SHUT_RD(0):关闭连接的“读”这个方向,对该套接字进行读操作直接返回 EOF。从数据角度来看,套接字上接收缓冲区已有的数据将被丢弃,如果再有新的数据流到达,会对数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根本不知道数据已经被丢弃了。
  • SHUT_WR(1):关闭连接的“写”这个方向,这就是常被称为“半关闭”的连接。此时,不管套接字引用计数的值是多少,都会直接关闭连接的写方向。套接字上发送缓冲区已有的数据将被立即发送出去,并发送一个 FIN 报文给对端。应用程序如果对该套接字进行写操作会报错。
  • SHUT_RDWR(2):相当于 SHUT_RDSHUT_WR 操作各一次,关闭套接字的读和写两个方向。

closeshutdown的区别:

  • close会关闭连接并释放资源,而shutdown并不会释放掉套接字和所有的资源。
  • close存在引用计数的概念,并不一定导致该套接字不可用;shutdown则不管引用计数,直接使得该套接字不可用,如果有别的进程企图使用该套接字,将会受到影响。
  • close因为引用计数的存在导致不一定会发出FIN结束报文,而shutdown则总会发出FIN结束报文。

参考资料:
极客时间—网络编程实战11


无效连接的检测

在没有数据读写的TCP连接上,无法发现TCP连接是有效的还是无效的。
TCP有一个保持活跃的机制叫Keep-Alive,这个机制的原理是:定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。
在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,默认值如下:

net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75  
net.ipv4.tcp_keepalive_probes=9
  • tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
  • tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
  • tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。

也就是说Linux下开启TCP保活机制后需要2小时1115秒才能发现一个无效的连接。

使用TCP保活机制需要通过socket接口设置SO_KEEPALIVE选项

如果开启了 TCP 保活,需要考虑以下几种情况:

  • 对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
  • 对端程序崩溃并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置。
  • 对端程序崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡

TCP 保活机制默认是关闭的,当我们选择打开时,可以分别在连接的两个方向上开启,也可以单独在一个方向上开启:

  • 如果开启服务器端到客户端的检测,就可以在客户端非正常断连的情况下清除在服务器端保留的“脏数据”;
  • 而开启客户端到服务器端的检测,就可以在服务器无响应的情况下,重新发起连接。

TCP默认的保活机制后需要2小时1115秒才可以发现一个无效连接,对很多时延敏感的系统中,这个时间间隔是不可接受的。
可以在应用程序中模拟实现TCP Keep-Alive机制,来完成在应用层的连接探活。

参考资料:
极客时间—网络编程实战12
客户端出现了故障怎么办


小数据包传输

有几个发送小数据包的场景:

  1. 接收方告知发送方可以发送一个小窗口,发送方就发送了一个小数据包。
  2. 交互式场景中,每次发送给服务器的命令字符都很少,且很频繁。
  3. 接收端对收到的每个TCP分组进行确认,也就是发送ACK报文,但是ACK报文本身不带数据,每次发送数据有固定的TCP首部和IP首部的开效。

优化的方法是:

  1. 第一个场景叫做糊涂窗口综合征,这个场景需要在接收端进行优化。也就是说,接收端不能在接收缓冲区空出一个很小的部分之后,就急吼吼地向发送端发送窗口更新通知,而是需要在自己的缓冲区大到一个合理的值之后,再向发送端发送窗口更新通知。这个合理的值,由对应的 RFC 规范定义。
  2. 第二个场景需要在发送端进行优化。这个优化的算法叫做 Nagle 算法,Nagle 算法的本质其实就是限制大批量的小数据包同时发送,为此,它提出,在任何一个时刻,未被确认的小数据包不能超过一个。这里的小数据包,指的是长度小于最大报文段长度 MSSTCP 分组。这样,发送端就可以把接下来连续的几个小数据包存储起来,等待接收到前一个小数据包的 ACK 分组之后,再将数据一次性发送出去。
  3. 第三个场景,也是需要在接收端进行优化,这个优化的算法叫做延时 ACK。延时 ACK 在收到数据后并不马上回复,而是累计需要发送的 ACK 报文,等到有数据需要发送给对端时,将累计的 ACK捎带一并发送出去。当然,延时 ACK 机制,不能无限地延时下去,否则发送端误认为数据包没有发送成功,引起重传,反而会占用额外的网络带宽。

Nagle算法和延时ACK的优化是互相冲突的,不能一起使用

除非我们对此有十足的把握,否则不要轻易改变默认的 TCP Nagle 算法。因为在现代操作系统中,针对 Nagle 算法和延时 ACK 的优化已经非常成熟了,有可能在禁用 Nagle 算法之后,性能问题反而更加严重。

总结:

  • 发送窗口用来控制发送和接收端的流量;阻塞窗口用来控制多条连接公平使用的有限带宽。
  • 小数据包加剧了网络带宽的浪费,为了解决这个问题,引入了如 Nagle 算法、延时 ACK 等机制。
  • 在程序设计层面,不要多次频繁地发送小报文,如果有,可以使用 writev 批量发送。

参考资料:
极客时间—网络编程实战13


已连接UDP

无连接的UDP中,在服务端不开启的情况下,客户端程序是不会报错的,程序只会阻塞在recvfrom上,等待返回(或者超时)。
已连接UDP的作用:

  • 如果对UDP socket进行了connect操作,帮助操作系统内核建立了(UDP 套接字——目的地址 + 端口)之间的映射关系,当收到一个 ICMP 不可达报文时,操作系统内核可以从映射表中找出是哪个 UDP 套接字拥有该目的地址和端口,套接字在操作系统内部是全局唯一的,当我们在该套接字上再次调用 recvfromrecv 方法时,就可以收到操作系统内核返回的“Connection Refused”的信息。
  • 一般来说,客户端通过 connect 绑定服务端的地址和端口,对 UDP 而言,可以有一定程度的性能提升。这是为什么呢?因为如果不使用 connect 方式,每次发送报文都会需要这样的过程:连接套接字→发送报文→断开套接字→连接套接字→发送报文→断开套接字 →………而如果使用 connect 方式,就会变成下面这样:连接套接字→发送报文→发送报文→……→最后断开套接字。所以,UDP 客户端程序通过 connect 可以获得一定的性能提升。

参考资料:
极客时间—网络编程实战14


地址复用

在网络通信中,一个端口只能被一个进程使用,不能多个进程共用同一个端口。我们在进行套接字通信的时候,如果按顺序执行如下操作:先启动服务器程序,再启动客户端程序,然后关闭服务器进程,再退出客户端进程,最后再启动服务器进程,就会出如下的错误提示信息:bind error: Address already in use,这是因为服务器主动关闭后处于TIME_WAIT状态,IP+Port仍被占用。
在这里插入图片描述
之前提到过TIME_WAIT状态是为了防止旧的连接的信息被新的连接接收到,一个TCP连接是由一个四元组(源地址、源端口、目的地址、目的端口)标志的,这种情况下只要客户端使用的本地端口不同,就不会和旧的四元组冲突,也就不会由TIME_WAIT的旧连接的信息被新连接错误接收的情况发生。
即使很小概率情况下新旧连接四元组相同,在现代Linux操作系统下,也不会有大的问题,原因是现代 Linux 操作系统对此进行了一些优化:

  • 第一种优化是新连接 SYN 告知的初始序列号,一定比 TIME_WAIT 老连接的末序列号大,这样通过序列号就可以区别出新老连接。
  • 第二种优化是开启了 tcp_timestamps,使得新连接的时间戳比老连接的时间戳大,这样通过时间戳也可以区别出新老连接。

在这样的优化之下,一个TIME_WAITTCP连接可以忽略掉旧连接,重新被新的连接所使用。
通过给socket设置SO_REUSEADDR,这样的TCP连接就可以完全复用TIME_WAIT状态的连接

  • SO_REUSEADDR需要在bind之前设置
  • SO_REUSEADDR 套接字选项还有一个作用,那就是本机服务器如果有多个地址,可以在不同地址上使用相同的端口提供服务。

这里很容易跟之前的tcp_tw_reuse混淆,其实这两个东西一点关系也没有:

  • tcp_tw_reuse 是内核选项,主要用在连接的发起方。TIME_WAIT 状态的连接创建时间超过 1 秒后,新的连接才可以被复用,注意,这里是连接的发起方;
  • SO_REUSEADDR 是用户态的选项,SO_REUSEADDR 选项用来告诉操作系统内核,如果端口已被占用,但是 TCP 连接状态位于 TIME_WAIT ,可以重用端口。如果端口忙,而 TCP 处于其他状态,重用端口时依旧得到“Address already in use”的错误信息。注意,这里一般都是连接的服务方。

最佳实践: 服务器端程序,都应该设置 SO_REUSEADDR 套接字选项,以便服务端程序可以在极短时间内复用同一个端口启动。

参考资料:
极客时间—网络编程实战15


粘包问题

TCP是一个无边界的流式传输协议,发送方一次发送了一个完整的包,接收方有可能分两次才接收到,编程时需要自己确定数据包的边界。
有几种可用的分包的方式:
1.添加包头:数组包的头部添加长度和其它需要的字段:
在这里插入图片描述
2. 使用特殊的字符作为边界,比如HTTP协议使用回车换行符作为边界:
在这里插入图片描述

参考资料:
极客时间—网络编程实战16
TCP数据粘包的处理


TCP异常处理

TCP 协议实现并没有提供给上层应用程序过多的异常处理细节,或者说,TCP 协议反映链路异常的能力偏弱。连接建立之后,能感知 TCP 链路的方式是有限的,一种是以 read 为核心的读操作,另一种是以 write 为核心的写操作。
实际情境中,我们会碰到各种异常的情况,可以归为两大类:
在这里插入图片描述
参考资料:
极客时间—网络编程实战17


性能篇

I/O多路复用

可以把标准输入、套接字等都看做 I/O 的一路,多路复用的意思,就是在任何一路I/O 有“事件”发生的情况下,通知应用程序去处理相应的 I/O 事件。
比如使用 I/O 复用以后,如果标准输入有数据,立即从标准输入读入数据,通过套接字发送出去;如果套接字有数据可以读,立即可以读出数据。
通过I/O多路复用通知内核挂起进程,当一个或多个 I/O 事件发生后,控制权返还给应用程序,由应用程序进行 I/O 事件的处理。
这些I/O 事件的类型非常多,比如:

  • 标准输入文件描述符准备好可以读
  • 监听套接字准备好,新的连接已经建立成功。
  • 已连接套接字准备好可以写。
  • 如果一个 I/O 事件等待超过了 10 秒,发生了超时事件。

可以实现I/O多路复用的函数有selectpollepoll,使用方式可以参见:
IO多路转接(复用)之select
IO多路转接(复用)之poll
IO多路转接(复用)之epoll
它们的对比如下:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

参考资料:
极客时间—网络编程实战20、21、23
IO多路转接(复用)之select
IO多路转接(复用)之poll
IO多路转接(复用)之epoll
大并发服务器开发


非阻塞I/O

阻塞I/O执行操作时,应用程序会被挂起,直到I/O操作完成才返回。
非阻塞I/O执行操作时,则不论I/O事件是否完成立即返回,这样程序不会被挂起,返回后可以继续执行其他操作。
对于readwrite在阻塞模式和非阻塞模式下的行为特性总结如下:
在这里插入图片描述
虽然说非阻塞I/O可以不让出CPU使用权继续执行,然后如果真的没有数据可读写的话,一直在应用程序轮询也是浪费CPU,所以非阻塞I/O一般配合I/O多路复用使用,委托内核轮询,等到有读写事件发生再继续执行I/O操作。
在非阻塞 TCP 套接字上调用 connect 函数,会立即返回一个 EINPROGRESS 错误。TCP 三次握手会正常进行,应用程序可以继续做其他初始化的事情。当该连接建立成功或者失败时,通过 I/O 多路复用 selectpoll 等可以进行连接的状态检测。
对于监听套接字,一般也要设置为非阻塞。如果有新连接到来但是没有马上accept,有可能客户端发送了RSTf分节将连接重置,此时如果用的是非阻塞的监听套接字且没有新的连接建立的话,程序会一直阻塞在accept处。

参考资料:
极客时间—网络编程实战22


C10K问题

C10K 问题是这样的:如何在一台物理机上同时服务 10000 个用户?
服务器通常会在本地监听某个端口,这样理论上服务器能够连接 2 48 2^{48} 248个连接(由四元组的客户端IP+Port得到)。
但实际上服务器支持的连接受系统资源的限制:

  • 文件描述符的限制:在Linux下,单个进程打开的文件句柄数是有限制的。
  • 内存限制:每个连接都要占用一定的内存资源。
  • 网络带宽限制:假设 1 万个连接,每个连接每秒传输大约 1KB 的数据,那么带宽需要 10000 x 1KB/s x8 = 80Mbps

在系统资源层面,C10K问题可以解决。
但是实际上还要考虑频繁的用户态-内核态数据拷贝的开销,以及如何保证在高并发下还能良好工作。
要想解决 C10K 问题,就需要从两个层面上来统筹考虑:

  • 第一个层面,应用程序如何和操作系统配合,感知 I/O 事件发生,并调度处理在上万个套接字上的 I/O 操作?
  • 第二个层面,应用程序如何分配进程、线程资源来服务上万个连接?

这两个层面的组合就形成了解决 C10K 问题的几种解法方案,也就是之后提到的网络编程模型。

参考资料:
极客时间—网络编程实战24


网络编程模型

任何一个网络程序,所做的事情可以总结成下面几种:

  • read:从套接字收取数据;
  • decode:对收到的数据进行解析;
  • compute:根据解析之后的内容,进行计算和处理;
  • encode:将处理之后的结果,按照约定的格式进行编码;
  • send:最后,通过套接字把结果发送出去。

针对这些操作有如下网络编程模型可以处理:

  • 阻塞I/O+多进程:主进程负责监听连接事件,有新连接到来时fork出一个进程来为该连接服务。这种方式实现简单,但为每一个连接创建一个进程的开销过大。
    在这里插入图片描述
  • 阻塞I/O+多线程:主线程负责监听连接事件,有新连接到来时创建出一个线程来为该连接服务。这种方式相比阻塞I/O+多进程的方式来说,创建线程的开效更小,但是频繁创建和销毁线程的开销仍然不小,且随着连接数越来越多,线程上下文切换的开销也很大。
    关于多进程和多线程模型的实现可参考服务器并发
    在这里插入图片描述
  • 阻塞I/O + 线程池:预先创建好一部分线程放入线程池,这部分线程不重复创建销毁。主线程负责接收连接,将连接放入队列中,线程池中的线程从队列中取出文件描述符处理。这样相比阻塞I/O+多线程的方式少了重复创建销毁线程和创建过多线程带来的上下文切换开销。
    在这里插入图片描述
  • Reactor模型:配合I/O多路复用,采用事件驱动的方式处理I/O事件,一个reactor线程同时负责处理连接事件和I/O事件。但是如果有某个I/O事件处理时间长的话,其他I/O事件的处理时间就会受到影响,所以这种模型不适合处理CPU密集型的业务。
    在这里插入图片描述
  • Reactor+ThreadPoolreactor线程只负责处理连接事件,相应的I/O事件交给子线程处理。这种方式相比单Reactor模型充分利用了多核CPU的性能,但是因为I/O事件只由Reactor线程处理,如果短时间内I/O事件过多的话,reactor线程可能分发不过来。
    在这里插入图片描述
  • 主从Reactor模型:主线程和子线程都是reactor线程,不同的是主线程只负责监听连接事件,连接建立后,将连接分配给子线程,由子线程负责监听新连接的I/O事件。这样相比于单Reactor的方式,主Reactor不必再监听所有连接。
    在这里插入图片描述
    参考资料:
    极客时间—网络编程实战25、26、27、28
    服务器并发
    muduo源码学习

参考资料

极客时间—网络编程实战
Linux教程
图解网络
muduo源码学习
大并发服务器开发

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值