准备一个场景
如下场景:Linux 服务器 A 和 Linux 服务器 B 处于不同的网段,通过中间的 Linux 服务器作为路由器进行转发。
说起网络协议,我们必须要先了解一下两中网络协议模型,一种是OSI的标准七层模式,一种是业界标准的TCP/IP模型。它们之间的对应关系如下图:
我们这里简单介绍一下网络协议的几个层次。
(1)我们先从第三层,网络层开始。
(2)从第三层往下看,可以看到数据链路层
(3)从第二层往下看就是第一层,物理层,这一层就是物理设备。比如网线
(4)从第三层往上看,就是传输层,这里面由两个著名的协议TCP和UDP。
(5)从第四层传输层再往上,就要区分网络包是发给哪个应用。在传输层TCP和UDP协议里面,都有端口的概念,不同的应用监听不同的端口。
应用层和内核互通的机制,就是通过socket系统调用。那socket属于那一层呢?其实它哪一层都不属于,它属于操作系统的概念,而非网络协议分层的概念。只不过操作系统选择对于网络协议的实现模式是,二到四层的处理代码在内核里面,七层的处理代码让应用自己去做,两者需要跨内核态和用户态通信,就需要一个系统调用完成这个衔接,这就是socket
网络分完层之后,对于数据包的发送,就是层层封装的过程。
如下图:
socket接口大多数情况下操作的是传输层,更底层的协议不用它来操心,这就是分层的好处。
在传输层有两个主流的协议TCP和UDP,所以我们的socket程序设计也是主要操作这两个协议。这两个协议的区别是什么呢?通常的答案是下面这样的
这些答案没有问题,但是没有到达本质,也经常会让人产生错觉。比如,下面这些问题:
从本质上连接,所谓的建立连接,其实是为了在客户端和服务端维护连接,而建立一定的数据结构来维护双方交互的状态,并用这些的数据结构来保证面向连接的特性。TCP无法左右中间的任何通路,也没有什么虚拟的连接,中间的通路根本意识不到两端使用了TCP还是UDP。
所谓的连接,就是两端数据结构状态的协同,两边的状态能够对得上。符号TCP协议的规则,就认为连接存在;两面状态对不上,连接就算断了
流量控制和拥塞控制其实就是根据收到的对端的网络包,调整两端数据结构的状态。TCP协议的设计理论上认为,这样调整了数据结构的状态,就能进行流量控制和拥塞控制了,其实在通道上是不是真的做到了,谁也管不着。
所谓的“可靠”,就是两端的数据结构做的事情。不丢失其实是数据结构在“点名”,顺序到达其实是数据结构在“排序”,面向数据流其实是数据结构将零散的包,按照顺序捏成一个流发给应用层。总而言之,“连接”两个字让人误以为功夫在通路,其实功夫在两端。
当然,无论是用 socket 操作 TCP,还是 UDP,我们首先都要调用 socket 函数。
int socket(int domain, int type, int protocol);
socket函数用于创建一个socket的文件描述符,唯一标识一个socket。我们把它叫做文件描述符,因为在内核中,我们会创建类似文件系统的数据结构,并且后继的操作都有用到它。
socket 函数有三个参数。
通信结束后,我们还要像关闭文件一样,关闭 socket。
TCP服务端要先监听一个端口,一般是调用bind函数,给这个socket赋予一个端口和IP地址:
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
struct sockaddr_in {
__kernel_sa_family_t sin_family; /* Address family */
__be16 sin_port; /* Port number */
struct in_addr sin_addr; /* Internet address */
/* Pad to size of `struct sockaddr'. */
unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -
sizeof(unsigned short int) - sizeof(struct in_addr)];
};
struct in_addr {
__be32 s_addr;
};
其中,sockfd 是上面我们创建的 socket 文件描述符。在 sockaddr_in 结构中,sin_family 设置为 AF_INET,表示 IPv4;sin_port 是端口号;sin_addr 是 IP 地址。
服务端所在的服务器可能有多个网卡、多个地址,可以选择监听在一个地址,也可以监听0.0.0.0表示所有的地址都监听。服务端一般要监听在一个众所周知的端口上。
客户端要访问服务端,就一定要事先直到服务端的端口。当然,只有客户端主动去连接别人,别人不会主动连接客户端,没有人关心客户端监听到了哪里,所以客户端不需要bind。
上面代码中的数据结构,里面的变量名称都有“be”两个字母,代表的意思是“big-endian”。如果在网络上传输超过 1 Byte 的类型,就要区分大端(Big Endian)和小端(Little Endian)。
假设,我们要在 32 位 4 Bytes 的一个空间存放整数 1,很显然只要 1 Byte 放 1,其他 3 Bytes 放 0 就可以了。那问题是,最后一个 Byte 放 1 呢,还是第一个 Byte 放 1 呢?或者说,1 作为最低位,应该放在 32 位的最后一个位置呢,还是放在第一个位置呢?
最低位放在最后一个位置,我们叫作小端,最低位放在第一个位置,叫作大端。TCP/IP栈是按照大端来设计的,而x86基本是小端设计,因而发出去时需要做一个转换。
接下来,就要建立TCP的连接了,也就是著名的三次握手,其实就是将客户端和服务端的状态通过三次网络交互,达到初始状态是协同的状态。
接下来,服务端要调用 listen 进入 LISTEN 状态,等待客户端进行连接。
int listen(int sockfd, int backlog);
连接的建立过程,也就是三次握手,是TCP层的动作,是在内核完成的,应用层不需要参与。
接着,服务端只需要调用accept,等待内核完成了至少一个连接的建立,才返回。如果没有一个连接完成了三次握手,accept就一直等待;如果有多个客户端发起连接,并且在内核里面完成了多个三次握手,建立了多个连接,这些连接会被放在一个队列里面。accept会从队列中取出一个来进行处理。如果想要进一步处理其他连接,需要调用多次accept,所以accept往往在一个循环里
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
接下来,客户端可以通过 connect 函数发起连接。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
我们先在参数中指明要连接的IP地址和端口号,然后发起三次握手。内核会给客户端分配一个临时的端口。一旦握手成功,服务端的accept会返回另一个socket。
这里需要注意的是,监听的socket和真正用来传输数据的socket,是两个socket,一个叫做监听socket,一个叫做已连接socket。成功连接建立之后,双方开始通过read和write函数来读写数据,就像完一个文件流里面写东西一样。
接下来我们来看,针对 UDP 应该如何编程。
UDP是没有连接的,所以不需要三次握手,也就不需要调用listen和connect,但是UDP的交互仍然需要IP地址和端口号,因而也需要bind。
对于UDP来讲,没有所谓的连接维护,也没有所谓的连接的发起方和接收方,甚至都不存在客户端和服务端的概念。大家都是客户端,也同时是服务端。只要有一个socket,多台机器就可以任意通信,不存在哪两台机器是属于一个连接的概念。因此,每一个UDP的socket都需要bind,。每次通信时,调用 sendto 和 recvfrom,都要传入 IP 地址和端口
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);