一、网络编程基础


一、TCP/IP网络协议

01 网络模型

02 客户端-网络模型的基本概念

在网络编程中,具体到客户端-服务器模型时,经常会考虑是使用TCP还是UDP,其实它们二者的区别也很简单:TCP中连接是谁发起的,在UDP中报文是谁发送的。在TCP通信中,建立连接是一个非常重要的环节。区别出客户端和服务器,本质上是因为二者编程模型是不同的。

服务器端需要在一开始就监听在一个众所周知的端口上,等待客户端发送请求,一旦有客户端连接建立,服务器端就会消耗一定的计算机资源为它服务,服务器端是需要同时为成千上万的客户端服务的。如何保证服务器端在数据量巨大的客户端访问时依然能维持效率和稳定,这也是我们讲述高性能网络编程的目的。

客户端相对来说更为简单,它向服务器端的监听端口发起连接请求,连接建立之后,通过连接通路和服务器端进行通信。

还有一点需要强调的是,无论是客户端,还是服务器端,它们运行的单位都是进程(process),而不是机器。一个客户端,比如我们的手机终端,同一个时刻可以建立多个到不同服务器的连接,比如同时打游戏,上知乎,逛天猫;而服务器端更是可能在一台机器上部署运行了多个服务,比如同时开启了SSH服务和HTTP服务。

在网络世界里,同样也需要地址的概念。在TCP/IP协议栈中,IP用来表示网络世界的地址。

在一台计算机上是可以同时存在多个连接的,那么如何区分出不同的连接呢?

这里就必须提到端口这个概念。计算机的IP地址是唯一的,每个连接的端口号是不同的。

端口号是一个16位的整数,最多为65536。当一个客户端发起连接请求时,客户端的端口是由操作系统内核临时分配的,称为临时端口;服务器端的端口通常是一个众所周知的端口。

一个连接可以通过客户端-服务器端的IP和端口唯一确定,这叫做套接字对,按照下面的四元组表示:

(clientaddr:clientport, serveraddr: serverport)

下图表示了一个客户端-服务器之间的连接:

03 套接字和地址

socket这个英文单词的原意是“插口”“插槽”, 在网络编程中,它的寓意是可以通过插口接入的方式,快速完成网络连接和数据收发。可以把它想象成现实世界的电源插口,或者是早期上网需要的网络插槽,所以socket也可以看做是对物理世界的直接映射。

socket是什么?

在网络编程中,到底应该怎么理解socket呢?

在客户端发起连接请求之前,服务器端必须初始化好。右侧的图显示的是服务器端初始化的过程,首先初始化socket,之后服务器端需要执行bind函数,将自己的服务能力绑定在一个众所周知的地址和端口上,紧接着,服务器端执行listen操作,将原先的socket转化为服务端的socket,服务端最后阻塞在accept上等待客户端请求的到来。

此时,服务器端已经准备就绪。客户端需要先初始化socket再执行connect向服务器端的地址和端口发起连接请求,这里的地址和端口必须是客户端预先知晓的。这个过程,就是著名的 TCP三次握手(Three-way Handshake)。

一旦三次握手完成,客户端和服务器端建立连接,就进入了数据传输过程。

具体来说,客户端进程向操作系统内核发起write字节流写操作,内核协议栈将字节流通过网络设备传输到服务器端,服务器端从内核得到信息,将字节流从内核读入到进程中,并开始业务逻辑的处理,完成之后,服务器端再将得到的结果以同样的方式写给客户端。可以看到, 一旦连接建立,数据的传输就不再是单向的,而是双向的,这也是TCP的一个显著特性

当客户端完成和服务器端的交互后,比如执行一次Telnet操作,或者一次HTTP请求,需要和服务器端断开连接时,就会执行close函数,操作系统内核此时会通过原先的连接链路向服务器端发送一个FIN包,服务器收到之后执行被动关闭,这时候整个链路处于半关闭状态,此后,服务器端也会执行close函数,整个链路才会真正关闭。半关闭的状态下,发起close请求的一方在没有收到对方FIN包之前都认为连接是正常的;而在全关闭的状态下,双方都感知连接已经关闭。

无论是客户端的connect,还是服务端的accept,或者read/write操作等, 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 */
  };

第一个字段是地址族,它表示使用什么样的方式对地址进行解释和保存,好比电话簿里的手机格式,或者是固话格式,这两种格式的长度和含义都是不同的。地址族在glibc里的定义非常多,常用的有以下几种:

  • AF_LOCAL:表示的是本地地址,对应的是Unix套接字,这种情况一般用于本地socket通信,很多情况下也可以写成AF_UNIX、AF_FILE;
  • AF_INET:因特网使用的IPv4地址;
  • AF_INET6:因特网使用的IPv6地址。

这里的AF_表示的含义是Address Family,但是很多情况下,我们也会看到以PF_表示的宏,比如PF_INET、PF_INET6等,实际上PF_的意思是Protocol Family,也就是协议族的意思。我们用AF_xxx这样的值来初始化socket地址,用PF_xxx这样的值来初始化socket。我们在<sys/socket.h>头文件中可以清晰地看到,这两个值本身就是一一对应的。

/* 各种地址族的宏定义  */
#define AF_UNSPEC PF_UNSPEC
#define AF_LOCAL  PF_LOCAL
#define AF_UNIX   PF_UNIX
#define AF_FILE   PF_FILE
#define AF_INET   PF_INET
#define AF_AX25   PF_AX25
#define AF_IPX    PF_IPX
#define AF_APPLETALK  PF_APPLETALK
#define AF_NETROM PF_NETROM
#define AF_BRIDGE PF_BRIDGE
#define AF_ATMPVC PF_ATMPVC
#define AF_X25    PF_X25
#define AF_INET6  PF_INET6

sockaddr是一个通用的地址结构,通用的意思是适用于多种地址族。

IPv4套接字格式地址

常用的IPv4地址族的结构:

/* IPV4套接字地址,32bit值.  */
typedef uint32_t in_addr_t;
struct in_addr
  &#123;
    in_addr_t s_addr;
  &#125;;

/* 描述IPV4的套接字地址格式  */
struct sockaddr_in
  &#123;
    sa_family_t sin_family; /* 16-bit */
    in_port_t sin_port;     /* 端口号  16-bit*/
    struct in_addr sin_addr;    /* Internet address. 32-bit */

    /* 这里仅仅用作占位符,不做实际用处  */
    unsigned char sin_zero[8];
  &#125;;

首先可以发现和sockaddr一样,都有一个16-bit的sin_family字段,对于IPv4来说这个值就是AF_INET。

接下来是端口号,端口号最多是16-bit,也就是说最大支持2的16次方,这个数字是65536,支持寻址的端口号最多就是65535。所谓保留端口就是大家约定俗成的,已经被对应服务广为使用的端口,比如ftp的21端口,ssh的22端口,http的80端口等。一般而言,大于5000的端口可以作为我们自己应用程序的端口使用。

下面是glibc定义的保留端口。

/* Standard well-known ports.  */
enum
  &#123;
    IPPORT_ECHO = 7,    /* Echo service.  */
    IPPORT_DISCARD = 9,   /* Discard transmissions service.  */
    IPPORT_SYSTAT = 11,   /* System status service.  */
    IPPORT_DAYTIME = 13,  /* Time of day service.  */
    IPPORT_NETSTAT = 15,  /* Network status service.  */
    IPPORT_FTP = 21,    /* File Transfer Protocol.  */
    IPPORT_TELNET = 23,   /* Telnet protocol.  */
    IPPORT_SMTP = 25,   /* Simple Mail Transfer Protocol.  */
    IPPORT_TIMESERVER = 37, /* Timeserver service.  */
    IPPORT_NAMESERVER = 42, /* Domain Name Service.  */
    IPPORT_WHOIS = 43,    /* Internet Whois service.  */
    IPPORT_MTP = 57,

    IPPORT_TFTP = 69,   /* Trivial File Transfer Protocol.  */
    IPPORT_RJE = 77,
    IPPORT_FINGER = 79,   /* Finger service.  */
    IPPORT_TTYLINK = 87,
    IPPORT_SUPDUP = 95,   /* SUPDUP protocol.  */

    IPPORT_EXECSERVER = 512,  /* execd service.  */
    IPPORT_LOGINSERVER = 513, /* rlogind service.  */
    IPPORT_CMDSERVER = 514,
    IPPORT_EFSSERVER = 520,

    /* UDP ports.  */
    IPPORT_BIFFUDP = 512,
    IPPORT_WHOSERVER = 513,
    IPPORT_ROUTESERVER = 520,

    /* Ports less than this value are reserved for privileged processes.  */
    IPPORT_RESERVED = 1024,

    /* Ports greater this value are reserved for (non-privileged) servers.  */
    IPPORT_USERRESERVED = 5000

实际的IPv4地址是一个32-bit的字段,可以想象最多支持的地址数就是2的32次方,大约是42亿,应该说这个数字在设计之初还是非常巨大的,无奈互联网蓬勃发展,全球接入的设备越来越多,这个数字渐渐显得不太够用了,于是大家所熟知的IPv6就隆重登场了。

IPv6套接字地址格式

我们再看看IPv6的地址结构:

struct sockaddr_in6
  {
    sa_family_t sin6_family; /* 16-bit */
    in_port_t sin6_port;  /* 传输端口号 # 16-bit */
    uint32_t sin6_flowinfo; /* IPv6流控信息 32-bit*/
    struct in6_addr sin6_addr;  /* IPv6地址128-bit */
    uint32_t sin6_scope_id; /* IPv6域ID 32-bit */
  };

整个结构体长度是28个字节,其中流控信息和域ID先不用管,这两个字段,一个在glibc的官网上根本没出现,另一个是当前未使用的字段。这里的地址族显然应该是AF_INET6,端口同IPv4地址一样,关键的地址从32位升级到128位,这个数字就大到恐怖了,完全解决了寻址数字不够的问题。

以上无论IPv4还是IPv6的地址格式都是因特网套接字的格式,还有一种本地套接字格式,用来作为本地进程间的通信, 也就是前面提到的AF_LOCAL。

struct sockaddr_un {
    unsigned short sun_family; /* 固定为 AF_LOCAL */
    char sun_path[108];   /* 路径名 */
};

几种套接字地址格式比较

这几种地址的比较见下图,IPv4和IPv6套接字地址结构的长度是固定的,而本地地址结构的长度是可变的。

04 TCP三次握手

服务端准备连接的过程

创建套接字

要创建一个可用的套接字,需要使用下面的函数:

int socket(int domain, int type, int protocol)

domain就是指PF_INET、PF_INET6以及PF_LOCAL等,表示什么样的套接字。

type可用的值是:

  • SOCK_STREAM: 表示的是字节流,对应TCP;
  • SOCK_DGRAM: 表示的是数据报,对应UDP;
  • SOCK_RAW: 表示的是原始套接字。

参数protocol原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成。protocol目前一般写成0即可。

bind: 设定电话号码

创建出来的套接字如果需要被别人使用,就需要调用bind函数把套接字和套接字地址绑定,就像去电信局登记我们的电话号码一样。

调用bind函数的方式如下:

bind(int fd, sockaddr * addr, socklen_t len)

我们需要注意到bind函数后面的第二个参数是通用地址格式 sockaddr * addr。这里有一个地方值得注意,那就是虽然接收的是通用地址格式,实际上传入的参数可能是IPv4、IPv6或者本地套接字格式。bind函数会根据len字段判断传入的参数addr该怎么解析,len字段表示的就是传入的地址长度,它是一个可变值。

这里其实可以把bind函数理解成这样:

bind(int fd, void * addr, socklen_t len)

不过BSD设计套接字的时候大约是1982年,那个时候的C语言还没有 void * 的支持,为了解决这个问题,BSD的设计者们创造性地设计了通用地址格式来作为支持bind和accept等这些函数的参数。

对于使用者来说,每次需要将IPv4、IPv6或者本地套接字格式转化为通用套接字格式,就像下面的IPv4套接字地址格式的例子一样:

struct sockaddr_in name;
bind (sock, (struct sockaddr *) &name, sizeof (name)

对于实现者来说,可根据该地址结构的前两个字节判断出是哪种地址。为了处理长度可变的结构,需要读取函数里的第三个参数,也就是len字段,这样就可以对地址进行解析和判断了。

设置bind的时候,对地址和端口可以有多种处理方式。

我们可以把地址设置成本机的IP地址,这相当告诉操作系统内核,仅仅对目标IP是本机IP地址的IP包进行处理。但是这样写的程序在部署时有一个问题,我们编写应用程序时并不清楚自己的应用程序将会被部署到哪台机器上。这个时候,可以利用 通配地址 的能力帮助我们解决这个问题。通配地址相当于告诉操作系统内核:“Hi,我可不挑活,只要目标地址是咱们的都可以。”比如一台机器有两块网卡,IP地址分别是202.61.22.55和192.168.1.11,那么向这两个IP请求的请求包都会被我们编写的应用程序处理。

那么该如何设置通配地址呢?

对于IPv4的地址来说,使用INADDR_ANY来完成通配地址的设置;对于IPv6的地址来说,使用IN6ADDR_ANY来完成通配地址的设置。

struct sockaddr_in name;
name.sin_addr.s_addr = htonl (INADDR_ANY); /* IPV4通配地址 */

除了地址,还有端口。如果把端口设置成0,就相当于把端口的选择权交给操作系统内核来处理,操作系统内核会根据一定的算法选择一个空闲的端口,完成套接字的绑定。这在服务器端不常使用。

一般来说,服务器端的程序一定要绑定到一个众所周知的端口上。服务器端的IP地址和端口数据,相当于打电话拨号时需要知道的对方号码,如果没有电话号码,就没有办法和对方建立连接。

一个初始化IPv4 TCP 套接字的例子:

#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>

int make_socket (uint16_t port)
&#123;
  int sock;
  struct sockaddr_in name;

  /* 创建字节流类型的IPV4 socket. */
  sock = socket (PF_INET, SOCK_STREAM, 0);
  if (sock < 0)
    &#123;
      perror ("socket");
      exit (EXIT_FAILURE);
    &#125;

  /* 绑定到port和ip. */
  name.sin_family = AF_INET; /* IPV4 */
  name.sin_port = htons (port);  /* 指定端口 */
  name.sin_addr.s_addr = htonl (INADDR_ANY); /* 通配地址 */
  /* 把IPV4地址转换成通用地址格式,同时传递长度 */
  if (bind (sock, (struct sockaddr *) &name, sizeof (name)) < 0)
    &#123;
      perror ("bind");
      exit (EXIT_FAILURE);
    &#125;

  return sock;
&#125;

listen:接上电话线,一切准备就绪

bind函数只是让我们的套接字和地址关联,如同登记了电话号码。如果要让别人打通电话,还需要我们把电话设备接入电话线,让服务器真正处于可接听的状态,这个过程需要依赖listen函数。

初始化创建的套接字,可以认为是一个”主动”套接字,其目的是之后主动发起请求(通过调用connect函数,后面会讲到)。通过listen函数,可以将原来的”主动”套接字转换为”被动”套接字,告诉操作系统内核:“我这个套接字是用来等待用户请求的。”当然,操作系统内核会为此做好接收用户请求的一切准备,比如完成连接队列。

listen函数的原型是这样的:

int listen (int socketfd, int backlog)

第一个参数socketdf为套接字描述符,第二个参数backlog,在Linux中表示已完成(ESTABLISHED)且未accept的队列大小,这个参数的大小决定了可以接收的并发数目。这个参数越大,并发数目理论上也会越大。但是参数过大也会占用过多的系统资源,一些系统,比如Linux并不允许对这个参数进行改变。

accept: 电话铃响起了……

当客户端的连接请求到达时,服务器端应答成功,连接建立,这个时候操作系统内核需要把这个事件通知到应用程序,并让应用程序感知到这个连接。这个过程,就好比电信运营商完成了一次电话连接的建立, 应答方的电话铃声响起,通知有人拨打了号码,这个时候就需要拿起电话筒开始应答。

连接建立之后,把accept这个函数看成是操作系统内核和应用程序之间的桥梁。它的原型是:

int accept(int listensockfd, struct sockaddr *cliaddr, socklen_t *addrlen)

函数的第一个参数listensockfd是套接字,可以叫它为listen套接字,因为这就是前面通过bind,listen一系列操作而得到的套接字。函数的返回值有两个部分,第一个部分cliadd是通过指针方式获取的客户端的地址addrlen告诉我们地址的大小,这可以理解成当我们拿起电话机时,看到了来电显示,知道了对方的号码;另一个部分是函数的返回值,这个返回值是一个全新的描述字,代表了与客户端的连接

这里一定要注意有两个套接字描述字,第一个是监听套接字描述字listensockfd,它是作为输入参数存在的;第二个是返回的已连接套接字描述字。

为什么要把两个套接字分开呢?

这里和打电话的情形非常不一样的地方就在于,打电话一旦有一个连接建立,别人是不能再打进来的,只会得到语音播报:“您拨的电话正在通话中。”而网络程序的一个重要特征就是并发处理,不可能一个应用程序运行之后只能服务一个客户。

所以监听套接字一直都存在,它是要为成千上万的客户来服务的,直到这个监听套接字关闭;而一旦一个客户和服务器连接成功,完成了TCP三次握手,操作系统内核就为这个客户生成一个已连接套接字,让应用服务器使用这个 已连接套接字 和客户进行通信处理。如果应用服务器完成了对这个客户的服务,比如一次网购下单,一次付款成功,那么关闭的就是 已连接套接字,这样就完成了TCP连接的释放。请注意,这个时候释放的只是这一个客户连接,其它被服务的客户连接可能还存在。最重要的是,监听套接字一直都处于“监听”状态,等待新的客户请求到达并服务。

客户端发起连接的过程

前面讲述的bind、listen以及accept的过程,是典型的服务器端的过程。下面是客户端发起连接请求的过程。

第一步还是和服务端一样,要建立一个套接字,方法和前面是一样的。

不一样的是客户端需要调用connect向服务端发起请求。

connect: 拨打电话

客户端和服务器端的连接建立,是通过connect函数完成的。这是connect的构建函数:

int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen)

函数的第一个参数sockfd是连接套接字,通过前面讲述的socket函数创建。第二个、第三个参数servaddr和addrlen分别代表指向套接字地址结构的指针和该结构的大小。套接字地址结构必须含有服务器的IP地址和端口号

客户在调用函数connect前不必非得调用bind函数,因为如果需要的话,内核会确定源IP地址,并按照一定的算法选择一个临时端口作为源端口。

如果是TCP套接字,那么调用connect函数将激发TCP的三次握手过程,而且仅在连接建立成功或出错时才返回。其中出错返回可能有以下几种情况:

  1. 三次握手无法建立,客户端发出的SYN包没有任何响应,于是返回TIMEOUT错误。这种情况比较常见的原因是对应的服务端IP写错。
  2. 客户端收到了RST(复位)回答,这时候客户端会立即返回CONNECTION REFUSED错误。这种情况比较常见于客户端发送连接请求时的请求端口写错,因为RST是TCP在发生错误时发送的一种TCP分节。产生RST的三个条件是目的地为某端口的SYN到达,然而该端口上没有正在监听的服务器(如前所述);TCP想取消一个已有连接;TCP接收到一个根本不存在的连接上的分节。
  3. 客户发出的SYN包在网络上引起了”destination unreachable”,即目的不可达的错误。这种情况比较常见的原因是客户端和服务器端路由不通。

根据不同的返回值,我们可以做进一步的排查。

著名的TCP三次握手

dd

这里使用的网络编程模型都是阻塞式的。所谓阻塞式,就是调用发起后不会直接返回,由操作系统内核处理之后才会返回。 相对的,还有一种叫做非阻塞式的。

TCP三次握手的解读

先看一下最初的过程,服务器端通过socket,bind和listen完成了被动套接字的准备工作,被动的意思就是等着别人来连接,然后调用accept,就会阻塞在这里,等待客户端的连接来临;客户端通过调用socket和connect函数之后,也会阻塞。接下来的事情是由操作系统内核完成的,更具体一点的说,是操作系统内核网络协议栈在工作。

下面是具体的过程:

  1. 客户端的协议栈向服务器端发送了SYN包,并告诉服务器端当前发送序列号j,客户端进入SYNC_SENT状态;
  2. 服务器端的协议栈收到这个包之后,和客户端进行ACK应答,应答的值为j+1,表示对SYN包j的确认,同时服务器也发送一个SYN包,告诉客户端当前我的发送序列号为k,服务器端进入SYNC_RCVD状态;
  3. 客户端协议栈收到ACK之后,使得应用程序从connect调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为ESTABLISHED,同时客户端协议栈也会对服务器端的SYN包进行应答,应答数据为k+1;
  4. 应答包到达服务器端后,服务器端协议栈使得accept阻塞调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入ESTABLISHED状态。

形象一点的比喻是这样的,有A和B想进行通话:

  • A先对B说:“喂,你在么?我在的,我的口令是j。”
  • B收到之后大声回答:“我收到你的口令j并准备好了,你准备好了吗?我的口令是k。”
  • A收到之后也大声回答:“我收到你的口令k并准备好了,我们开始吧。”

可以看到,这样的应答过程总共进行了三次,这就是TCP连接建立之所以被叫为“三次握手”的原因了。

总结

如何创建套接字,并利用套接字完成TCP连接的建立。

  • 服务器端通过创建socket,bind,listen完成初始化,通过accept完成连接的建立。
  • 客户端通过创建socket,connect发起连接建立请求。

问题

1.既然有阻塞调用,就应该有非阻塞调用,那么如何使用非阻塞调用套接字呢?使用的场景又是哪里呢?

答:非阻塞套接字往往配合多路复用机制,达到提高CPU利用率,实现高并发的目的, 如何使用非阻塞调用套接字:使用fcntl函数设置套接字的属性fcntl(fd, F_SETFL, flags); 非阻塞调用的使用的场景:程序在调用返回之前,需要做其他事情,可以选择用定时轮询或事件通知的方式获取调用结果。

非阻塞调用的场景就是高性能服务器编程!我所有的调用都不需要等待对方准备好了再返回,而是立即返回,那么我怎么知道是否准备好了?就是把这些fd注册到类似select或者epoll这样的调用中,变多个fd阻塞为一个fd阻塞,只要有任何一个fd准备好了,select或者epoll都会返回,然后我们在从中取出准备好了的fd进行各种IO操作

2.客户端发起connect调用之前,可以调用bind函数么?

可以,但是调用bind函数,也就是客户端指定了端口号,这样容易造成端口冲突,所以客户端不调用bind函数,让系统自动选择空闲端口比较好

3.什么是TCP的三次握手机制?

假设只有客户端C和服务端S,两台机器, TCP的三次握手机制,是C和S使用TCP协议进行通信,他们在正式建立链接前,先进行了三次简单的通信,只有这三次简单的同学成功了,才建立正式的连接,之所以说是简单的通信,因为这三次通信发送的消息比较简单,就是固定格式的同步报文和确认报文

4.TCP三次握手机制的目的是什么?

TCP协议的设计目标之一,就是保证通信信道的安全,而TCP的三次握手机制就是用于确认信道是否安全的手段之一,确认信道安全,最基本的首先要确认C和S都具备信息首发的能力吧!也就是C要确定S能收发信息,S要确定C能收发信息,咋确认呢?那就发送一条消息实验一下呗!所以,就有了TCP的三次握手机制,TCP的三次握手机制的核心目标就是要确认C和S之间的通信信道是安全的。

5.为什么是三次握手?四次不行吗?

按道理来讲,C要确认S是否能够正确的收发消息,需要发生一条消息给S,然后接收到S的一条确认收到的消息才行,这一来一回就是两条消息。同理,S要确认C

是否能够正确的收发消息,也需要这么玩。这样就需要两趟一来一回,总共需要四次通信,其实这么玩思维上一点负载都是没有的自然而然。不过只是为了确认

C和S能否正常通信的话,就如此设计,被聪明一看到就会骂傻X,人类孜孜不倦所追求是更快、更高、更强,计算机世界中这种追求更加的强烈,将S的确认收到C发送的消息和S能正常发生的消息一次性的都发给C岂不是更好,虽然增加了点消息的内容,但是相对于消息的传输消耗而言还是非常少的,而且从整体消耗上看,是减少了一次通信的过程,性能想必会更好。很明显一次握手、两次握手都确认不了C和S的收发消息的能力是否OK。三次握手是比较简洁有效的方式,大于三次之上的握手机制也可以确认C和S是否能够正常通信,不过有些浪费资源了,毕竟三次就能搞定的事情,没必要搞三次至少,毕竟对于性能的追求我们是纳秒必争的。

05 使用套接字进行读写

如何使用创建的套接字收发数据。

连接建立的根本目的是为了数据的收发。拿我们常用的网购场景举例子,我们在浏览商品或者购买货品的时候,并不会察觉到网络连接的存在,但是我们可以真切感觉到数据在客户端和服务器端有效的传送, 比如浏览商品时商品信息的不断刷新,购买货品时显示购买成功的消息等。

首先我来看一下发送数据。

发送数据

发送数据时常用的有三个函数,分别是write、send和sendmsg。

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)

每个函数都是单独使用的,使用的场景略有不同:

第一个函数是常见的文件写函数,如果把socketfd换成文件描述符,就是普通的文件写入。

如果想指定选项,发送带外数据,就需要使用第二个带flag的函数。所谓带外数据,是一种基于TCP协议的紧急数据,用于客户端-服务器在特定场景下的紧急处理。

如果想指定多重缓冲区传输数据,就需要使用第三个函数,以结构体msghdr的方式发送数据。

既然套接字描述符是一种特殊的描述符,那么在套接字描述符上调用write函数,应该和在普通文件描述符上调用write函数的行为是一致的,都是通过描述符句柄写入指定的数据。乍一看,两者的表现形式是一样,内在的区别还是很不一样的。

对于普通文件描述符而言,一个文件描述符代表了打开的一个文件句柄,通过调用write函数,操作系统内核帮我们不断地往文件系统中写入字节流。注意,写入的字节流大小通常和输入参数size的值是相同的,否则表示出错。

对于套接字描述符而言,它代表了一个双向连接,在套接字描述符上调用write写入的字节数 有可能 比请求的数量少,这在普通文件描述符情况下是不正常的。

产生这个现象的原因在于操作系统内核为读取和发送数据做了很多我们表面上看不到的工作。接下来我拿write函数举例,重点阐述发送缓冲区的概念。

发送缓冲区

当TCP三次握手成功,TCP连接成功建立后,操作系统内核会为每一个连接创建配套的基础设施,比如 发送缓冲区

发送缓冲区的大小可以通过套接字选项来改变,当我们的应用程序调用write函数时,实际所做的事情是把数据 从应用程序中拷贝到操作系统内核的发送缓冲区中,并不一定是把数据通过套接字写出去。

这里有几种情况:

第一种情况很简单,操作系统内核的发送缓冲区足够大,可以直接容纳这份数据,那么皆大欢喜,我们的程序从write调用中退出,返回写入的字节数就是应用程序的数据大小。

第二种情况是,操作系统内核的发送缓冲区是够大了,不过还有数据没有发送完,或者数据发送完了,但是操作系统内核的发送缓冲区不足以容纳应用程序数据,在这种情况下,你预料的结果是什么呢?报错?还是直接返回?

操作系统内核并不会返回,也不会报错,而是应用程序被阻塞,也就是说应用程序在write函数调用处停留,不直接返回。术语“挂起”也表达了相同的意思,不过“挂起”是从操作系统内核角度来说的。

那么什么时候才会返回呢?

实际上,每个操作系统内核的处理是不同的。大部分UNIX系统的做法是一直等到可以把应用程序数据完全放到操作系统内核的发送缓冲区中,再从系统调用中返回。怎么理解呢?

别忘了,我们的操作系统内核是很聪明的,当TCP连接建立之后,它就开始运作起来。你可以把发送缓冲区想象成一条包裹流水线,有个聪明且忙碌的工人不断地从流水线上取出包裹(数据),这个工人会按照TCP/IP的语义,将取出的包裹(数据)封装成TCP的MSS包,以及IP的MTU包,最后走数据链路层将数据发送出去。这样我们的发送缓冲区就又空了一部分,于是又可以继续从应用程序搬一部分数据到发送缓冲区里,这样一直进行下去,到某一个时刻,应用程序的数据可以完全放置到发送缓冲区里。在这个时候,write阻塞调用返回。注意返回的时刻,应用程序数据并没有全部被发送出去,发送缓冲区里还有部分数据,这部分数据会在稍后由操作系统内核通过网络发送出去。

读取数据

我们可以注意到,套接字描述本身和本地文件描述符并无区别, 在UNIX的世界里万物都是文件,这就意味着可以将套接字描述符传递给那些原先为处理本地文件而设计的函数。这些函数包括read和write交换数据的函数。

read函数

让我们先从最简单的read函数开始看起,这个函数的原型如下:

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

read函数要求操作系统内核从套接字描述字socketfd 读取最多多少个字节(size),并将结果存储到buffer中。返回值告诉我们实际读取的字节数目,也有一些特殊情况,如果返回值为0,表示EOF(end-of-file),这在网络中表示对端发送了FIN包,要处理断连的情况;如果返回值为-1,表示出错。当然,如果是非阻塞I/O,情况会略有不同,在后面的提高篇中我们会重点讲述非阻塞I/O的特点。

注意这里是最多读取size个字节。如果我们想让应用程序每次都读到size个字节,就需要编写下面的函数,不断地循环读取。

/* 从socketfd描述字中读取"size"个字节. */
size_t readn(int fd, void *buffer, size_t size) {
    char *buffer_pointer = buffer;
    int length = size;

    while (length > 0) {
        int result = read(fd, buffer_pointer, length);

        if (result < 0) {
            if (errno == EINTR)
                continue;     /* 考虑非阻塞的情况,这里需要再次调用read */
            else
                return (-1);
        } else if (result == 0)
            break;                /* EOF(End of File)表示套接字关闭 */

        length -= result;
        buffer_pointer += result;
    }
    return (size - length);        /* 返回的是实际读取的字节数*/
}

对这个程序稍微解释下:

  • 6-19行的循环条件表示的是,在没读满size个字节之前,一直都要循环下去。
  • 10-11行表示的是非阻塞I/O的情况下,没有数据可以读,需要继续调用read。
  • 14-15行表示读到对方发出的FIN包,表现形式是EOF,此时需要关闭套接字。
  • 17-18行,需要读取的字符数减少,缓存指针往下移动。
  • 20行是在读取EOF跳出循环后,返回实际读取的字符数。

缓冲区实验

我们用一个客户端-服务器的例子来解释一下读取缓冲区和发送缓冲区的概念。在这个例子中客户端不断地发送数据,服务器端每读取一段数据之后进行休眠,以模拟实际业务处理所需要的时间。

服务器端读取数据程序

下面是服务器端读取数据的程序:

#include "lib/common.h"

void read_data(int sockfd) {
    ssize_t n;
    char buf[1024];

    int time = 0;
    for (;;) {
        fprintf(stdout, "block in read\n");
        if ((n = readn(sockfd, buf, 1024)) == 0)
            return;

        time++;
        fprintf(stdout, "1K read for %d \n", time);
        usleep(1000);
    }
}

int main(int argc, char **argv) {
    int listenfd, connfd;
    socklen_t clilen;
    struct sockaddr_in cliaddr, servaddr;

    listenfd = socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(12345);

    /* bind到本地地址,端口为12345 */
    bind(listenfd, (struct sockaddr *) &servaddr, sizeof(servaddr));
    /* listen的backlog为1024 */
    listen(listenfd, 1024);

    /* 循环处理用户请求 */
    for (;;) {
        clilen = sizeof(cliaddr);
        connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &clilen);
        read_data(connfd);   /* 读取数据 */
        close(connfd);          /* 关闭连接套接字,注意不是监听套接字*/
    }
}

对服务器端程序解释如下:

  • 21-35行先后创建了socket套接字,bind到对应地址和端口,并开始调用listen接口监听;
  • 38-42行循环等待连接,通过accept获取实际的连接,并开始读取数据;
  • 8-15行实际每次读取1K数据,之后休眠1秒,用来模拟服务器端处理时延。

客户端发送数据程序

下面是客户端发送数据的程序:

#include "lib/common.h"

#define MESSAGE_SIZE 102400

void send_data(int sockfd) {
    char *query;
    query = malloc(MESSAGE_SIZE + 1);
    for (int i = 0; i < MESSAGE_SIZE; i++) {
        query[i] = 'a';
    }
    query[MESSAGE_SIZE] = '\0';

    const char *cp;
    cp = query;
    size_t remaining = strlen(query);
    while (remaining) {
        int n_written = send(sockfd, cp, remaining, 0);
        fprintf(stdout, "send into buffer %ld \n", n_written);
        if (n_written <= 0) {
            error(1, errno, "send failed");
            return;
        }
        remaining -= n_written;
        cp += n_written;
    }

    return;
}

int main(int argc, char **argv) {
    int sockfd;
    struct sockaddr_in servaddr;

    if (argc != 2)
        error(1, 0, "usage: tcpclient <IPaddress>");

    sockfd = socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(12345);
    inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
    int connect_rt = connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr));
    if (connect_rt < 0) {
        error(1, errno, "connect failed ");
    }
    send_data(sockfd);
    exit(0);
}

对客户端程序解释如下:

  • 31-37行先后创建了socket套接字,调用connect向对应服务器端发起连接请求
  • 43行在连接建立成功后,调用send_data发送数据
  • 6-11行初始化了一个长度为MESSAGE_SIZE的字符串流
  • 16-25行调用send函数将MESSAGE_SIZE长度的字符串流发送出去

实验一: 观察客户端数据发送行为

客户端程序发送了一个很大的字节流,程序运行起来之后,我们会看到服务端不断地在屏幕上打印出读取字节流的过程:

而客户端直到最后所有的字节流发送完毕才打印出下面的一句话,说明在此之前send函数一直都是阻塞的,也就是说 阻塞式套接字最终发送返回的实际写入字节数和请求字节数是相等的。

而关于非阻塞套接字的操作,会在后面中讲解。

实验二: 服务端处理变慢

如果我们把服务端的休眠时间稍微调大,把客户端发送的字节数从10240000调整为1024000,再次运行刚才的例子,我们会发现,客户端很快打印出一句话:

但与此同时,服务端读取程序还在屏幕上不断打印读取数据的进度,显示出服务端读取程序还在辛苦地从缓冲区中读取数据。

通过这个例子想再次强调一下:

发送成功仅仅表示的是数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到所有的数据。至于什么时候发送到对端的接收缓冲区,或者更进一步说,什么时候被对方应用程序缓冲所接收,对我们而言完全都是透明的。

总结

这一讲重点讲述了通过send和read来收发数据包,你需要牢记以下两点:

  • 对于send来说,返回成功仅仅表示数据写到发送缓冲区成功,并不表示对端已经成功收到。
  • 对于read来说,需要循环读取数据,并且需要考虑EOF等异常条件。

思考题

既然缓冲区如此重要,我们可不可以把缓冲区搞得大大的,这样不就可以提高应用程序的吞吐量了么?另外一段数据流从应用程序发送端,一直到应用程序接收端,总共经过了多少次拷贝?

无限大肯定是不行的,这要从为什么使用缓存这个角度考虑。内核协议栈不确定用户一次要发多少数据,如果用户来一次就发一次,如果数据多还好说,如果少了,那网络I/0很频繁,而真正发送出去的数据也不多,所以为了减少网络I/0使用了缓存的策略。但为啥不呢无限大呢,网卡一次发出去的数据报它是有一个最大长度的,所以你不管累积再多数据最后还是要分片发送的,这样一来缓冲区太大也没什么意义,而且数据传输也是有延时要求的,不可能总是在缓冲区里待着等数据,这样就总会有空出来的缓冲区存放新数据,所以无限大缓冲区也没意义,反而还浪费资源。
发送端,假设数据能一次性复制完,那么从用户态内存拷贝到内核态内存是一次(这里应该直接拷贝到发送换冲区了),传输层组TCP包是第二次拷贝,因为要加包头,而发送缓冲区的都是紧凑内存全是应用层数据,那么分装包就需要一次拷贝,第三次,一个TCP包封装为IP报文这里可能也会需要一次拷贝,与毕竟这里走到协议栈的下一层了。

06 UDP

首先,UDP是一种“数据报”协议,而TCP是一种面向连接的“数据流”协议。

TCP可以用日常生活中打电话的场景打比方,前面也多次用到了这样的例子。在这个例子中,拨打号码、接通电话、开始交流,分别对应了TCP的三次握手和报文传送。一旦双方的连接建立,那么双方对话时,一定知道彼此是谁。这个时候我们就说,这种对话是有上下文的。

同样的,我们也可以给UDP找一个类似的例子,这个例子就是邮寄明信片。在这个例子中,发信方在明信片中填上了接收方的地址和邮编,投递到邮局的邮筒之后,就可以不管了。发信方也可以给这个接收方再邮寄第二张、第三张,甚至是第四张明信片,但是这几张明信片之间是没有任何关系的,他们的到达顺序也是不保证的,有可能最后寄出的第四张明信片最先到达接收者的手中,因为没有序号,接收者也不知道这是第四张寄出的明信片;而且,即使接收方没有收到明信片,也没有办法重新邮寄一遍该明信片。

这两个简单的例子,道出了UDP和TCP之间最大的区别。

TCP是一个面向连接的协议,TCP在IP报文的基础上,增加了诸如重传、确认、有序传输、拥塞控制等能力,通信的双方是在一个确定的上下文中工作的。

而UDP则不同,UDP没有这样一个确定的上下文,它是一个不可靠的通信协议,没有重传和确认,没有有序控制,也没有拥塞控制。我们可以简单地理解为,在IP报文的基础上,UDP增加的能力有限。

UDP不保证报文的有效传递,不保证报文的有序,也就是说使用UDP的时候,我们需要做好丢包、重传、报文组装等工作。

既然如此,为什么我们还要使用UDP协议呢?

答案很简单,因为UDP比较简单,适合的场景还是比较多的,我们常见的DNS服务,SNMP服务都是基于UDP协议的,这些场景对时延、丢包都不是特别敏感。另外多人通信的场景,如聊天室、多人游戏等,也都会使用到UDP协议。

UDP编程

UDP和TCP编程非常不同,下面这张图是UDP程序设计时的主要过程。

我们看到服务器端创建UDP 套接字之后,绑定到本地端口,调用recvfrom函数等待客户端的报文发送;客户端创建套接字之后,调用sendto函数往目标地址和端口发送UDP报文,然后客户端和服务器端进入互相应答过程。

recvfrom和sendto是UDP用来接收和发送报文的两个主要函数:

#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);

我们先来看一下recvfrom函数。

sockfd、buff和nbytes是前三个参数。sockfd是本地创建的套接字描述符,buff指向本地的缓存,nbytes表示最大接收数据字节。

第四个参数flags是和I/O相关的参数,这里我们还用不到,设置为0。

后面两个参数from和addrlen,实际上是返回对端发送方的地址和端口等信息,这和TCP非常不一样,TCP是通过accept函数拿到的描述字信息来决定对端的信息。另外UDP报文每次接收都会获取对端的信息,也就是说报文和报文之间是没有上下文的。

函数的返回值告诉我们实际接收的字节数。

接下来看一下sendto函数。

sendto函数中的前三个参数为sockfd、buff和nbytes。sockfd是本地创建的套接字描述符,buff指向发送的缓存,nbytes表示发送字节数。第四个参数flags依旧设置为0。

后面两个参数to和addrlen,表示发送的对端地址和端口等信息

函数的返回值告诉我们实际发送的字节数。

我们知道, TCP的发送和接收每次都是在一个上下文中,类似这样的过程:

A连接上: 接收→发送→接收→发送→…

B连接上: 接收→发送→接收→发送→ …

而UDP的每次接收和发送都是一个独立的上下文,类似这样:

接收A→发送A→接收B→发送B →接收C→发送C→ …

UDP服务端例子

我们先来看一个UDP服务器端的例子:

#include "lib/common.h"

static int count;

static void recvfrom_int(int signo) {
    printf("\nreceived %d datagrams\n", count);
    exit(0);
}

int main(int argc, char **argv) {
    int socket_fd;
    socket_fd = socket(AF_INET, SOCK_DGRAM, 0);

    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(SERV_PORT);

    bind(socket_fd, (struct sockaddr *) &server_addr, sizeof(server_addr));

    socklen_t client_len;
    char message[MAXLINE];
    count = 0;

    signal(SIGINT, recvfrom_int);

    struct sockaddr_in client_addr;
    client_len = sizeof(client_addr);
    for (;;) {
        int n = recvfrom(socket_fd, message, MAXLINE, 0, (struct sockaddr *) &client_addr, &client_len);
        message[n] = 0;
        printf("received %d bytes: %s\n", n, message);

        char send_line[MAXLINE];
        sprintf(send_line, "Hi, %s", message);

        sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &client_addr, client_len);

        count++;
    }

}

程序的12~13行,首先创建一个套接字,注意这里的套接字类型是“SOCK_DGRAM”,表示的是UDP数据报。

15~21行和TCP服务器端类似,绑定数据报套接字到本地的一个端口上。

27行为该服务器创建了一个信号处理函数,以便在响应“Ctrl+C”退出时,打印出收到的报文总数。

31~42行是该服务器端的主体,通过调用recvfrom函数获取客户端发送的报文,之后我们对收到的报文进行重新改造,加上“Hi”的前缀,再通过sendto函数发送给客户端对端。

UDP客户端例子

接下来我们再来构建一个对应的UDP客户端。在这个例子中,从标准输入中读取输入的字符串后,发送给服务端,并且把服务端经过处理的报文打印到标准输出上。

#include "lib/common.h"

# define    MAXLINE     4096

int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: udpclient <IPaddress>");
    }

    int socket_fd;
    socket_fd = socket(AF_INET, SOCK_DGRAM, 0);

    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(SERV_PORT);
    inet_pton(AF_INET, argv[1], &server_addr.sin_addr);

    socklen_t server_len = sizeof(server_addr);

    struct sockaddr *reply_addr;
    reply_addr = malloc(server_len);

    char send_line[MAXLINE], recv_line[MAXLINE + 1];
    socklen_t len;
    int n;

    while (fgets(send_line, MAXLINE, stdin) != NULL) {
        int i = strlen(send_line);
        if (send_line[i - 1] == '\n') {
            send_line[i - 1] = 0;
        }

        printf("now sending %s\n", send_line);
        size_t rt = sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &server_addr, server_len);
        if (rt < 0) {
            error(1, errno, "send failed ");
        }
        printf("send bytes: %zu \n", rt);

        len = 0;
        n = recvfrom(socket_fd, recv_line, MAXLINE, 0, reply_addr, &len);
        if (n < 0)
            error(1, errno, "recvfrom failed");
        recv_line[n] = 0;
        fputs(recv_line, stdout);
        fputs("\n", stdout);
    }

    exit(0);
}

10~11行创建一个类型为“SOCK_DGRAM”的套接字。

13~17行,初始化目标服务器的地址和端口。

28~51行为程序主体,从标准输入中读取的字符进行处理后,调用sendto函数发送给目标服务器端,然后再次调用recvfrom函数接收目标服务器发送过来的新报文,并将其打印到标准输出上。

为了让你更好地理解UDP和TCP之间的差别,我们模拟一下UDP的三种运行场景,你不妨思考一下这三种场景的结果和TCP的到底有什么不同?

场景一:只运行客户端

如果我们只运行客户端,程序会一直阻塞在recvfrom上。

$ ./udpclient 127.0.0.1
1
now sending g1
send bytes: 2
<阻塞在这里>

还记得TCP程序吗?如果不开启服务端,TCP客户端的connect函数会直接返回“Connection refused”报错信息。而在UDP程序里,则会一直阻塞在这里

场景二:先开启服务端,再开启客户端

在这个场景里,我们先开启服务端在端口侦听,然后再开启客户端:

$./udpserver
received 2 bytes: g1
received 2 bytes: g2
$./udpclient 127.0.0.1
g1
now sending g1
send bytes: 2
Hi, g1
g2
now sending g2
send bytes: 2
Hi, g2

我们在客户端一次输入g1、g2,服务器端在屏幕上打印出收到的字符,并且可以看到,我们的客户端也收到了服务端的回应:“Hi,g1”和“Hi,g2”。

场景三: 开启服务端,再一次开启两个客户端

这个实验中,在服务端开启之后,依次开启两个客户端,并发送报文。

服务端:

$./udpserver
received 2 bytes: g1
received 2 bytes: g2
received 2 bytes: g3
received 2 bytes: g4

第一个客户端:

$./udpclient 127.0.0.1
now sending g1
send bytes: 2
Hi, g1
g3
now sending g3
send bytes: 2
Hi, g3

第二个客户端:

$./udpclient 127.0.0.1
now sending g2
send bytes: 2
Hi, g2
g4
now sending g4
send bytes: 2
Hi, g4

我们看到,两个客户端发送的报文,依次都被服务端收到,并且客户端也可以收到服务端处理之后的报文。

如果我们此时把服务器端进程杀死,就可以看到信号函数在进程退出之前,打印出服务器端接收到的报文个数。

$ ./udpserver
received 2 bytes: g1
received 2 bytes: g2
received 2 bytes: g3
received 2 bytes: g4
^C
received 4 datagrams

之后,我们再重启服务器端进程,并使用客户端1和客户端2继续发送新的报文,我们可以看到和TCP非常不同的结果。

以下就是服务器端的输出,服务器端重启后可以继续收到客户端的报文,这在TCP里是不可以的,TCP断联之后必须重新连接才可以发送报文信息。但是UDP报文的“无连接”的特点,可以在UDP服务器重启之后,继续进行报文的发送,这就是UDP报文“无上下文”的最好说明

$ ./udpserver
received 2 bytes: g1
received 2 bytes: g2
received 2 bytes: g3
received 2 bytes: g4
^C
received 4 datagrams
$ ./udpserver
received 2 bytes: g5
received 2 bytes: g6

第一个客户端:

$./udpclient 127.0.0.1
now sending g1
send bytes: 2
Hi, g1
g3
now sending g3
send bytes: 2
Hi, g3
g5
now sending g5
send bytes: 2
Hi, g5

第二个客户端:

$./udpclient 127.0.0.1
now sending g2
send bytes: 2
Hi, g2
g4
now sending g4
send bytes: 2
Hi, g4
g6
now sending g6
send bytes: 2
Hi, g6

总结

需要重点关注以下两点:

  • UDP是无连接的数据报程序,和TCP不同,不需要三次握手建立一条连接。
  • UDP程序通过recvfrom和sendto函数直接收发数据报报文。

思考题

在第一个场景中,recvfrom一直处于阻塞状态中,这是非常不合理的,这种情形应该怎么处理呢?另外,既然UDP是请求-应答模式的,那么请求中的UDP报文最大可以是多大呢?

第一个场景中,可以添加超时时间做处理,当然也可以自己实现一个复杂的请求-确认模式,那这样就跟TCP类似了,HTTP/3就是这样做的。
用UDP协议发送时,用sendto函数最大能发送数据的长度为:65535-IP头(20)-UDP头(8)=65507字节。用sendto函数发送数据时,如果发送数据长度大于该值,则函数会返回错误。由于IP有最大MTU,因此,
UDP 包的大小应该是 1500-IP头(20)- UDP头(8)= 1472(Bytes)

TCP 包的大小应该是 1500-IP头(20)-TCP头(20)=1460(Bytes)

07 本地套接字

实际上,本地套接字是IPC,也就是本地进程间通信的一种实现方式。除了本地套接字以外,其它技术,诸如管道、共享消息队列等也是进程间通信的常用方法,但因为本地套接字开发便捷,接受度高,所以普遍适用于在同一台主机上进程间通信的各种场景。

利用本地套接字完成可靠字节流和数据报两种协议。

从例子开始

现在最火的云计算技术是什么?无疑是Kubernetes和Docker。在Kubernetes和Docker的技术体系中,有很多优秀的设计,比如Kubernetes的CRI(Container Runtime Interface),其思想是将Kubernetes的主要逻辑和Container Runtime的实现解耦。

通过netstat命令查看Linux系统内的本地套接字状况,下面这张图列出了路径为/var/run/dockershim.socket的stream类型的本地套接字,可以清楚地看到开启这个套接字的进程为kubelet。kubelet是Kubernetes的一个组件,这个组件负责将控制器和调度器的命令转化为单机上的容器实例。为了实现和容器运行时的解耦,kubelet设计了基于本地套接字的客户端-服务器GRPC调用。

列表里还有docker-containerd.sock等其他本地套接字,是的,Docker其实也是大量使用了本地套接字技术来构建的。

如果我们在/var/run目录下将会看到docker使用的本地套接字描述符:

本地套接字概述

本地套接字一般也叫做UNIX域套接字,最新的规范已经改叫本地套接字。在前面的TCP/UDP例子中,我们经常使用127.0.0.1完成客户端进程和服务器端进程同时在本机上的通信,那么,这里的本地套接字又是什么呢?

本地套接字是一种特殊类型的套接字,和TCP/UDP套接字不同。TCP/UDP即使在本地地址通信,也要走系统网络协议栈,而本地套接字,严格意义上说提供了一种单主机跨进程间调用的手段,减少了协议栈实现的复杂度,效率比TCP/UDP套接字都要高许多类似的IPC机制还有UNIX管道、共享内存和RPC调用等

比如X Window实现,如果发现是本地连接,就会走本地套接字,工作效率非常高。

现在你可以回忆一下,在前面介绍套接字地址时,我们讲到了本地地址,这个本地地址就是本地套接字专属的。

本地字节流套接字

先从字节流本地套接字开始。

这是一个字节流类型的本地套接字服务器端例子。在这个例子中,服务器程序打开本地套接字后,接收客户端发送来的字节流,并往客户端回送了新的字节流。

#include  "lib/common.h"

int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: unixstreamserver <local_path>");
    }

    int listenfd, connfd;
    socklen_t clilen;
    struct sockaddr_un cliaddr, servaddr;

    listenfd = socket(AF_LOCAL, SOCK_STREAM, 0);
    if (listenfd < 0) {
        error(1, errno, "socket create failed");
    }

    char *local_path = argv[1];
    unlink(local_path);
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sun_family = AF_LOCAL;
    strcpy(servaddr.sun_path, local_path);

    if (bind(listenfd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {
        error(1, errno, "bind failed");
    }

    if (listen(listenfd, LISTENQ) < 0) {
        error(1, errno, "listen failed");
    }

    clilen = sizeof(cliaddr);
    if ((connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &clilen)) < 0) {
        if (errno == EINTR)
            error(1, errno, "accept failed");        /* back to for() */
        else
            error(1, errno, "accept failed");
    }

    char buf[BUFFER_SIZE];

    while (1) {
        bzero(buf, sizeof(buf));
        if (read(connfd, buf, BUFFER_SIZE) == 0) {
            printf("client quit");
            break;
        }
        printf("Receive: %s", buf);

        char send_line[MAXLINE];
        sprintf(send_line, "Hi, %s", buf);

        int nbytes = sizeof(send_line);

        if (write(connfd, send_line, nbytes) != nbytes)
            error(1, errno, "write error");
    }

    close(listenfd);
    close(connfd);

    exit(0);

}

我对这个程序做一个详细的解释:

  • 第12~15行非常关键, 这里创建的套接字类型,注意是AF_LOCAL,并且使用字节流格式。你现在可以回忆一下,TCP的类型是AF_INET和字节流类型;UDP的类型是AF_INET和数据报类型。在前面的文章中,我们提到AF_UNIX也是可以的,基本上可以认为和AF_LOCAL是等价的。
  • 第17~21行创建了一个本地地址,这里的本地地址和IPv4、IPv6地址可以对应,数据类型为sockaddr_un,这个数据类型中的sun_family需要填写为AF_LOCAL,最为关键的是需要对sun_path设置一个本地文件路径。我们这里还做了一个unlink操作,以便把存在的文件删除掉,这样可以保持幂等性。
  • 第23~29行,分别执行bind和listen操作,这样就监听在一个本地文件路径标识的套接字上,这和普通的TCP服务端程序没什么区别。
  • 第41~56行,使用read和write函数从套接字中按照字节流的方式读取和发送数据。

我在这里着重强调一下本地文件路径。关于本地文件路径,需要明确一点,它必须是“绝对路径”,这样的话,编写好的程序可以在任何目录里被启动和管理。如果是“相对路径”,为了保持同样的目的,这个程序的启动路径就必须固定,这样一来,对程序的管理反而是一个很大的负担。

另外还要明确一点,这个本地文件,必须是一个“文件”,不能是一个“目录”。如果文件不存在,后面bind操作时会自动创建这个文件。

还有一点需要牢记,在Linux下,任何文件操作都有权限的概念,应用程序启动时也有应用属主。如果当前启动程序的用户权限不能创建文件,会发生什么呢?

下面我们再看一下客户端程序。

#include "lib/common.h"

int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: unixstreamclient <local_path>");
    }

    int sockfd;
    struct sockaddr_un servaddr;

    sockfd = socket(AF_LOCAL, SOCK_STREAM, 0);
    if (sockfd < 0) {
        error(1, errno, "create socket failed");
    }

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sun_family = AF_LOCAL;
    strcpy(servaddr.sun_path, argv[1]);

    if (connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {
        error(1, errno, "connect failed");
    }

    char send_line[MAXLINE];
    bzero(send_line, MAXLINE);
    char recv_line[MAXLINE];

    while (fgets(send_line, MAXLINE, stdin) != NULL) {

        int nbytes = sizeof(send_line);
        if (write(sockfd, send_line, nbytes) != nbytes)
            error(1, errno, "write error");

        if (read(sockfd, recv_line, MAXLINE) == 0)
            error(1, errno, "server terminated prematurely");

        fputs(recv_line, stdout);
    }

    exit(0);
}
  • 11~14行创建了一个本地套接字,和前面服务器端程序一样,用的也是字节流类型SOCK_STREAM。
  • 16~18行初始化目标服务器端的地址。我们知道在TCP编程中,使用的是服务器的IP地址和端口作为目标,在本地套接字中则使用文件路径作为目标标识,sun_path这个字段标识的是目标文件路径,所以这里需要对sun_path进行初始化。
  • 20行和TCP客户端一样,发起对目标套接字的connect调用,不过由于是本地套接字,并不会有三次握手。
  • 28~38行从标准输入中读取字符串,向服务器端发送,之后将服务器端传输过来的字符打印到标准输出上。

总体上,我们可以看到,本地字节流套接字和TCP服务器端、客户端编程最大的差异就是套接字类型的不同。本地字节流套接字识别服务器不再通过IP地址和端口,而是通过本地文件。

只启动客户端

第一个场景中,我们只启动客户端程序:

$ ./unixstreamclient /tmp/unixstream.sock
connect failed: No such file or directory (2)

我们看到,由于没有启动服务器端,没有一个本地套接字在/tmp/unixstream.sock这个文件上监听,客户端直接报错,提示我们没有文件存在。

服务器端监听在无权限的文件路径上

还记得我们在前面卖的关子吗?在Linux下,执行任何应用程序都有应用属主的概念。在这里,我们让服务器端程序的应用属主没有/var/lib/目录的权限,然后试着启动一下这个服务器程序 :

$ ./unixstreamserver /var/lib/unixstream.sock
bind failed: Permission denied (13)

这个结果告诉我们启动服务器端程序的用户,必须对本地监听路径有权限。这个结果和你期望的一致吗?

试一下root用户启动该程序:

sudo ./unixstreamserver /var/lib/unixstream.sock
(阻塞运行中)

我们看到,服务器端程序正常运行了。

打开另外一个shell,我们看到/var/lib下创建了一个本地文件,大小为0,而且文件的最后结尾有一个(=)号。其实这就是bind的时候自动创建出来的文件。

$ ls -al /var/lib/unixstream.sock
rwxr-xr-x 1 root root 0 Jul 15 12:41 /var/lib/unixstream.sock=

如果我们使用netstat命令查看UNIX域套接字,就会发现unixstreamserver这个进程,监听在/var/lib/unixstream.sock这个文件路径上。

我们写的程序和鼎鼎大名的Kubernetes运行在同一机器上,原理和行为完全一致。

服务器-客户端应答

现在,我们让服务器和客户端都正常启动,并且客户端依次发送字符:

$./unixstreamserver /tmp/unixstream.sock
Receive: g1
Receive: g2
Receive: g3
client quit
$./unixstreamclient /tmp/unixstream.sock
g1
Hi, g1
g2
Hi, g2
g3
Hi, g3
^C

我们可以看到,服务器端陆续收到客户端发送的字节,同时,客户端也收到了服务器端的应答;最后,当我们使用Ctrl+C,让客户端程序退出时,服务器端也正常退出。

本地数据报套接字

我们再来看下在本地套接字上使用数据报的服务器端例子:

#include  "lib/common.h"

int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: unixdataserver <local_path>");
    }

    int socket_fd;
    socket_fd = socket(AF_LOCAL, SOCK_DGRAM, 0);
    if (socket_fd < 0) {
        error(1, errno, "socket create failed");
    }

    struct sockaddr_un servaddr;
    char *local_path = argv[1];
    unlink(local_path);
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sun_family = AF_LOCAL;
    strcpy(servaddr.sun_path, local_path);

    if (bind(socket_fd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {
        error(1, errno, "bind failed");
    }

    char buf[BUFFER_SIZE];
    struct sockaddr_un client_addr;
    socklen_t client_len = sizeof(client_addr);
    while (1) {
        bzero(buf, sizeof(buf));
        if (recvfrom(socket_fd, buf, BUFFER_SIZE, 0, (struct sockadd *) &client_addr, &client_len) == 0) {
            printf("client quit");
            break;
        }
        printf("Receive: %s \n", buf);

        char send_line[MAXLINE];
        bzero(send_line, MAXLINE);
        sprintf(send_line, "Hi, %s", buf);

        size_t nbytes = strlen(send_line);
        printf("now sending: %s \n", send_line);

        if (sendto(socket_fd, send_line, nbytes, 0, (struct sockadd *) &client_addr, client_len) != nbytes)
            error(1, errno, "sendto error");
    }

    close(socket_fd);

    exit(0);
}

本地数据报套接字和前面的字节流本地套接字有以下几点不同:

  • 第9行创建的本地套接字, 这里创建的套接字类型,注意是AF_LOCAL,协议类型为SOCK_DGRAM。
  • 21~23行bind到本地地址之后,没有再调用listen和accept,回忆一下,这其实和UDP的性质一样。
  • 28~45行使用recvfrom和sendto来进行数据报的收发,不再是read和send,这其实也和UDP网络程序一致。

然后我们再看一下客户端的例子:

#include "lib/common.h"

int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: unixdataclient <local_path>");
    }

    int sockfd;
    struct sockaddr_un client_addr, server_addr;

    sockfd = socket(AF_LOCAL, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        error(1, errno, "create socket failed");
    }

    bzero(&client_addr, sizeof(client_addr));        /* bind an address for us */
    client_addr.sun_family = AF_LOCAL;
    strcpy(client_addr.sun_path, tmpnam(NULL));

    if (bind(sockfd, (struct sockaddr *) &client_addr, sizeof(client_addr)) < 0) {
        error(1, errno, "bind failed");
    }

    bzero(&server_addr, sizeof(server_addr));
    server_addr.sun_family = AF_LOCAL;
    strcpy(server_addr.sun_path, argv[1]);

    char send_line[MAXLINE];
    bzero(send_line, MAXLINE);
    char recv_line[MAXLINE];

    while (fgets(send_line, MAXLINE, stdin) != NULL) {
        int i = strlen(send_line);
        if (send_line[i - 1] == '\n') {
            send_line[i - 1] = 0;
        }
        size_t nbytes = strlen(send_line);
        printf("now sending %s \n", send_line);

        if (sendto(sockfd, send_line, nbytes, 0, (struct sockaddr *) &server_addr, sizeof(server_addr)) != nbytes)
            error(1, errno, "sendto error");

        int n = recvfrom(sockfd, recv_line, MAXLINE, 0, NULL, NULL);
        recv_line[n] = 0;

        fputs(recv_line, stdout);
        fputs("\n", stdout);
    }

    exit(0);
}

这个程序和UDP网络编程的例子基本是一致的,我们可以把它当作是用本地文件替换了IP地址和端口的UDP程序,不过,这里还是有一个非常大的不同的。

这个不同点就在16~22行。你可以看到16~22行将本地套接字bind到本地一个路径上,然而UDP客户端程序是不需要这么做的。本地数据报套接字这么做的原因是,它需要指定一个本地路径,以便在服务器端回包时,可以正确地找到地址;而在UDP客户端程序里,数据是可以通过UDP包的本地地址和端口来匹配的。

下面这段代码就展示了服务器端和客户端通过数据报应答的场景:

 ./unixdataserver /tmp/unixdata.sock
Receive: g1
now sending: Hi, g1
Receive: g2
now sending: Hi, g2
Receive: g3
now sending: Hi, g3
$ ./unixdataclient /tmp/unixdata.sock
g1
now sending g1
Hi, g1
g2
now sending g2
Hi, g2
g3
now sending g3
Hi, g3
^C

我们可以看到,服务器端陆续收到客户端发送的数据报,同时,客户端也收到了服务器端的应答。

总结

我在开头已经说过,本地套接字作为常用的进程间通信技术,被用于各种适用于在同一台主机上进程间通信的场景。关于本地套接字,我们需要牢记以下两点:

  • 本地套接字的编程接口和IPv4、IPv6套接字编程接口是一致的,可以支持字节流和数据报两种协议。
  • 本地套接字的实现效率大大高于IPv4和IPv6的字节流、数据报套接字实现。

08 网络分析工具

其实在平常使用套接字开发和测试过程中,我们总会碰到这样或那样的问题。学会对这些问题进行诊断和分析,其实需要不断地积累经验。而Linux平台下提供的各种网络工具,则为我们进行诊断分析提供了很好的帮助。

必备工具: ping

这个命令我想大家都不陌生,“ping”这个命名来自于声呐探测,在网络上用来完成对网络连通性的探测,这个命名可以说是恰如其分了。

$ ping www.sina.com.cn
PING www.sina.com.cn (202.102.94.124) 56(84) bytes of data.
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=1 ttl=63 time=8.64 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=2 ttl=63 time=11.3 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=3 ttl=63 time=8.66 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=4 ttl=63 time=13.7 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=5 ttl=63 time=8.22 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=6 ttl=63 time=7.99 ms
^C
--- www.sina.com.cn ping statistics ---
6 packets transmitted, 6 received, 0% packet loss, time 5006ms
rtt min/avg/max/mdev = 7.997/9.782/13.795/2.112 ms

在上面的例子中,使用ping命令探测了和新浪网的网络连通性。可以看到,每次显示是按照sequence序列号排序显示的,一并显示的,也包括TTL(time to live),反映了两个IP地址之间传输的时间。最后还显示了ping命令的统计信息,如最小时间、平均时间等。

我们需要经常和Linux下的ping命令打交道,那么ping命令的原理到底是什么呢?它是基于TCP还是UDP开发的?

都不是。

其实,ping是基于一种叫做ICMP的协议开发的,ICMP又是一种基于IP协议的控制协议,翻译为网际控制协议,其报文格式如下图:

ICMP在IP报文后加入了新的内容,这些内容包括:

  • 类型:即ICMP的类型, 其中ping的请求类型为8,应答为0。
  • 代码:进一步划分ICMP的类型, 用来查找产生错误的原因。
  • 校验和:用于检查错误的数据。
  • 标识符:通过标识符来确认是谁发送的控制协议,可以是进程ID。
  • 序列号:唯一确定的一个报文,前面ping名字执行后显示的icmp_seq就是这个值。

当我们发起ping命令时,ping程序实际上会组装成如图的一个IP报文。报文的目的地址为ping的目标地址,源地址就是发送ping命令时的主机地址,同时按照ICMP报文格式填上数据,在可选数据上可以填上发送时的时间戳。

IP报文通过ARP协议,源地址和目的地址被翻译成MAC地址,经过数据链路层后,报文被传输出去。当报文到达目的地址之后,目的地址所在的主机也按照ICMP协议进行应答。之所以叫做协议,是因为双方都会遵守这个报文格式,并且也会按照格式进行发送-应答。

应答数据到达源地址之后,ping命令可以通过再次解析ICMP报文,对比序列号,计算时间戳等来完成每个发送-应答的显示,最终显示的格式就像前面的例子中展示的一样。

可以说,ICMP协议为我们侦测网络问题提供了非常好的支持。另外一种对路由的检测命令Traceroute也是通过ICMP协议来完成的,这里就不展开讲了。

基本命令: ifconfig

很多熟悉Windows的同学都知道Windows有一个ipconfig命令,用来显示当前的网络设备列表。事实上,Linux有一个对应的命令叫做ifconfig,也用来显示当前系统中的所有网络设备,通俗一点的说,就是网卡列表。

vagrant@ubuntu-xenial-01:~$ ifconfig
cni0      Link encap:Ethernet  HWaddr 0a:58:0a:f4:00:01
          inet addr:10.244.0.1  Bcast:0.0.0.0  Mask:255.255.255.0
          inet6 addr: fe80::401:b4ff:fe51:bcf9/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1450  Metric:1
          RX packets:2133 errors:0 dropped:0 overruns:0 frame:0
          TX packets:2216 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:139381 (139.3 KB)  TX bytes:853302 (853.3 KB)

docker0   Link encap:Ethernet  HWaddr 02:42:93:0f:f7:11
          inet addr:172.17.0.1  Bcast:0.0.0.0  Mask:255.255.0.0
          inet6 addr: fe80::42:93ff:fe0f:f711/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:653 errors:0 dropped:0 overruns:0 frame:0
          TX packets:685 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:49542 (49.5 KB)  TX bytes:430826 (430.8 KB)

enp0s3    Link encap:Ethernet  HWaddr 02:54:ad:ea:60:2e
          inet addr:10.0.2.15  Bcast:10.0.2.255  Mask:255.255.255.0
          inet6 addr: fe80::54:adff:feea:602e/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:7951 errors:0 dropped:0 overruns:0 frame:0
          TX packets:4123 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:5081047 (5.0 MB)  TX bytes:385600 (385.6 KB)

我稍微解释一下这里面显示的数据。

Link encap:Ethernet  HWaddr 02:54:ad:ea:60:2e

上面这段表明这是一个以太网设备,MAC地址为02:54:ad:ea:60:2e。

inet addr:10.0.2.15  Bcast:10.0.2.255  Mask:255.255.255.0
inet6 addr: fe80::54:adff:feea:602e/64 Scope:Link

这里显示的是网卡的IPv4和IPv6地址,其中IPv4还显示了该网络的子网掩码以及广播地址。

每个IPv4子网中,有一个特殊地址被保留作为子网广播地址,比如这里的10.0.2.255就是这个子网的广播地址。当向这个地址发送请求时,就会向以太网网络上的一组主机发送请求。

通常来说,这种被称作广播(broadcast)的技术,是用UDP来实现的

UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1

这里显示的是网卡的状态,MTU是最大传输单元的意思,表示的是链路层包的大小。1500表示的是字节大小。

Metric大家可能不知道是干啥用的,这里解释下,Linux在一台主机上可以有多个网卡设备,很可能有这么一种情况,多个网卡可以路由到目的地。一个简单的例子是在同时有无线网卡和有线网卡的情况下,网络连接是从哪一个网卡设备上出去的?Metric就是用来确定多块网卡的优先级的,数值越小,优先级越高,1为最高级。

          RX packets:7951 errors:0 dropped:0 overruns:0 frame:0
          TX packets:4123 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:5081047 (5.0 MB)  TX bytes:385600 (385.6 KB)

netstat和lsof:对网络状况了如指掌

在平时的工作中,我们最常碰到的问题就是某某进程对应的网络状况如何?是不是连接被打爆了?还是有大量的TIME_WAIT连接?

netstat可以帮助我们了解当前的网络连接状况,比如我想知道当前所有的连接详情,就可以使用下面这行命令:

netstat -alepn

可能的结果为:

netstat会把所有IPv4形态的TCP,IPV6形态的TCP、UDP以及UNIX域的套接字都显示出来。

对于TCP类型来说,最大的好处是可以清楚地看到一条TCP连接的四元组(源地址、源端口、目的地地址和目的端口)。

例如这里的一条信息:

tcp        0      0 127.0.0.1:2379          127.0.0.1:52464         ESTABLISHED 0          27710       3496/etcd

它表达的意思是本地127.0.0.1的端口52464连上本地127.0.0.1的端口2379,状态为ESTABLISHED,本地进程为etcd,进程为3496。

这在实战分析的时候非常有用,比如你可以很方便地知道,在某个时候是不是有很多TIME_WAIT的TCP连接,导致端口号被占用光,以致新的连接分配不了。

当然,我们也可以只对UNIX套接字进行筛查。

netstat Socket -x -alepn

UNIX套接字的结果稍有不同,最关键的信息是Path,这个信息显示了本地套接字监听的文件路径,比如这条:

unix  3      [ ]         STREAM     CONNECTED     23209    1400/dockerd        /var/run/docker.sock

这其实就是大名鼎鼎的Docker在本地套接字的监听路径。/var/run/docker.sock是本地套接字监听地址,dockerd是进程名称,1400是进程号。

netstat命令可以选择的参数非常之多,这里只关注了几个简单的场景,你可以通过帮助命令或者查阅文档获得更多的信息。

lsof的常见用途之一是帮助我们找出在指定的IP地址或者端口上打开套接字的进程,而netstat则告诉我们IP地址和端口使用的情况,以及各个TCP连接的状态。Isof和netstst可以结合起来一起使用。

比如说,我们可以通过lsof查看到底是谁打开了这个文件:

lsof /var/run/docker.sock

下面这张图显示了是dockerd打开了这个本地文件套接字:

lsof还有一个非常常见的用途。如果我们启动了一个服务器程序,发现这个服务器需要绑定的端口地址已经被占用,内核报出“该地址已在使用”的出错信息,我们可以使用lsof找出正在使用该端口的那个进程。比如下面这个代码,就帮我们找到了使用8080端口的那个进程,从而帮助我们定位问题。

lsof -i :8080

抓包利器: tcpdump

tcpdump这样的抓包工具对于网络编程而言是非常有用的,特别是在一些“山重水复疑无路”的情形下,通过tcpdump这样的抓包工具,往往可以达到“柳暗花明又一村”的效果。

tcpdump具有非常强大的过滤和匹配功能。

比如说指定网卡:

tcpdump -i eth0

再比如说指定来源:

tcpdump src host hostname

我们再来一个复杂一点的例子。这里抓的包是TCP,且端口是80,包来自IP地址为192.168.1.25的主机地址。

tcpdump 'tcp and port 80 and src host 192.168.1.25'

如果我们对TCP协议非常熟悉,还可以写出这样的tcpdump命令:

tcpdump 'tcp and port 80 and tcp[13:1]&2 != 0'

这里tcp[13:1]表示的是TCP头部开始处偏移为13的字节,如果这个值为2,说明设置了SYN分节,当然,我们也可以设置成其他值来获取希望类型的分节。注意,这里的偏移是从0开始算起的,tcp[13]其实是报文里的第14个字节。

tcpdump在开启抓包的时候,会自动创建一个类型为AF_PACKET的网络套接口,并向系统内核注册。当网卡接收到一个网络报文之后,它会遍历系统中所有已经被注册的网络协议,包括其中已经注册了的AF_PACKET网络协议。系统内核接下来就会将网卡收到的报文发送给该协议的回调函数进行一次处理,回调函数可以把接收到的报文完完整整地复制一份,假装是自己接收到的报文,然后交给tcpdump程序,进行各种条件的过滤和判断,再对报文进行解析输出。

下面这张图显示的是tcpdump的输出格式:

首先我们看到的是时间戳,之后类似192.168.33.11.41388 > 192.168.33.11.6443这样的,显示的是源地址(192.168.33.11.41388)到目的地址(192.168.33.11.6443);然后Flags [ ]是包的标志,[P]表示是数据推送,比较常见的包格式如下:

  • [S]:SYN,表示开始连接
  • [.]:没有标记,一般是确认
  • [P]:PSH,表示数据推送
  • [F]:FIN,表示结束连接
  • [R] :RST,表示重启连接

我们可以看到最后有几个数据,它们代表的含义如下:

  • seq:包序号,就是TCP的确认分组
  • cksum:校验码
  • win:滑动窗口大小
  • length:承载的数据(payload)长度length,如果没有数据则为0

此外,tcpdump还可以对每条TCP报文的细节进行显示,让我们可以看到每条报文的详细字节信息。这在对报文进行排查的时候很有用。

小结

这些工具需要你了解之后活学活用。用好它们,对加深网络编程的理解,以及对问题情况进行排查等都有非常大的帮助。

再来总结一下这几个命令的作用:

  • ping可以用来帮助我们进行网络连通性的探测。
  • ifconfig,用来显示当前系统中的所有网络设备。
  • netstat和lsof可以查看活动的连接状况。
  • tcpdump可以对各种奇怪的环境进行抓包,进而帮我们了解报文,排查问题。
  • iftop命令可以查网络io大户
  • arp
  • router
  • ss

思考题

tcpdump这个工具还可以对UDP包进行抓包处理吗?

tcpdump 抓取 1400 端口 UDP 报文并保存
tcpdump -i ens33 udp port -w ./udp.cap

netstat输出时,监听状态的套接字所对应的Foreign Address显示的*.*表示的是什么意思呢?

表示该套接字正在监听来自任何远程地址和端口的连接。这是一种非常常见的配置方式,特别是在服务器端的应用程序中。


文章作者: Merlin
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Merlin !
  目录