这篇文章主要是用于复习UNP的第4章,以及之前章节关于TCP/IP的基本知识。这部分内容主要包括TCP/IP握手协议、socket的结构、如何建立socket连接等。这部分是后面学习的基础,因此必须要牢牢的掌握。
IPV4套接字地址结构
1 |
|
sin_family
是unsigned short(16bit)
类型, sin_port
也是unsigned short(16bit)
类型,sin_addr
结构体中只含有一个unsigned int(32bit)
的变量,因此sin_addr
实际长度也和unsigned int(32bit)
一样。
一般套接字必须包含address family
, port number
和address
,长度大于等于16字节。这里定义的套接字占16字节,其中最后的__pad
部分必须置零。
有些函数,由于历史原因,并没有使用sockaddr_in
作为参数的类型,而是使用sockaddr
作为参数类型(比如connect
函数):
1 | struct sockaddr { |
我们可以将sockaddr_in
类型强制转换成sockaddr
类型,sin_port
, sin_addr
和__pad
就成了sa_data
。
字节排序和字符转换
不同硬件可能使用不同的字节排序(也就是我们说的大端和小端格式),但network传输数据时是按照大端的格式进行传输的。因此我们在收发数据时,需要使用以下函数对数据进行转换:
1 | // 下面函数返回主机/网络字节的值 |
上面函数中h
指的是本地主机,n
指的是network,s
是short, l
是long。因此htonl
就是将本地主机数据转换成network传输的数据类型,函数返回转换后的数据(如该数据小于1个字节,我们可以不用转换)。
除了数据需要转换,我们还需要对192.168.0.1
这样的字符串地址和network传输的地址进行转换,转换函数使用:
1 | // 成功返回1,其他情况返回0或-1 |
上面函数中p
指的是表达式,n
指的是数字。inet_pton
常用于设置sockaddr_in
结构体中的sin_addr
。
TCP 3次握手和4次挥手
这里我简单总结下TCP的连接和断开。TCP在进行连接时,客户端和服务器端需要通过进行3次握手来建立连接;TCP在断开时,客户端和服务器端需要通过进行4次挥手来断开连接。
3次握手
3次握手基本流程
这是一个简化版的3次握手示意图。服务器端首先调用socket
, bind
, listen
和accept
,然后被accept
阻塞;客户端在服务器端调用accept
后调用connect
去主动打开服务器端的socket;至于客户端的socket
,可以在accept
前或后被调用。
- 服务器端被
accept
阻塞;- 客户端调用
connect
然后被阻塞;同时发送SYN J
给服务器端;- 服务器端收到
SYNJ
后,发送SYN K
和ACK J+1
(ACK
的序列号是客户端SYN
序列号加1)给客户端;- 客户端收到
ACK J+1
后,知道连接成功,发送ACK K+1
给服务器端,并从connect
函数中返回;- 服务器端收到
ACK K+1
后,知道客户端已经成功连接,从accept
函数中返回。
上面就是TCP 3次握手的简化过程,这里并没有包括一些出错的情况:比如服务器端/客户端没有响应,连接到错误的服务器,网络不可到达等等。
为什么要3次握手?
只有进行至少3次握手,客户端和服务器端才能知道对方都准备好了。我们可以简单理解成,3次握手后,客户端和服务器端都知道对方可以读、可以写,然后就可以进行数据传输了。下面简单解释下:
比如第2次握手,服务器端已经知道自己可以接收数据(同时也知道客户端可以发送数据),但它不知道自己可不可以发送数据(也不知道客户端能不能接收数据),因此,服务器端发送ACK J+1
给客户。客户端在收到ACK J+1
前也不知道自己能不能发送/接收数据,当收到ACK J+1
后,它知道自己可以发送/接收数据了。接下来的第3次握手后,服务器端也知道自己可以发送/接收数据了。
4次挥手
当客户端断开连接(可能是直接调用close/shutdown
,也可能是线程被关掉)时,4次挥手就发生了。
- 客户端发送
FIN M
给服务器端;- 服务器端收到
FIN M
后,发送ACK M+1
给客户端;- 在之后某个时间,服务器端调用
close
函数,同时发送FIN N
给客户端;- 客户端收到
FIN N
后,回复一个ACK N+1
,然后客户端就断开了连接。
这里的流程和3次握手很像,都是发送一个信号,然后回复一个信号,回复信号的序列号是接收信号的序列号加1。
为什么要4次挥手?
4次挥手和3次握手相比,多出了一步,就是服务器端发送FIN N
给客户端,之所以要多出这步,是因为服务器端不会在接收到客户端关闭的信号后,立即关闭socket,而是会等待一段时间再调用close
函数,因此我们需要多一次挥手。
3次握手和4次挥手的状态转移图
这个是完整的3次握手、4次挥手流程图,这个流程中添加了客户端和服务器端的状态。下图是一个完整的TCP状态转移图:
这个图比较复杂,和图3一起看比较好。TIME_WAIT
是一个不太容易理解的状态,这里我不做深入研究了。
Socket编程API
最简单的socket编程只需要几个API即可,我这里总结一下服务器端和客户端分别需要什么样的API。
服务器端
图5是一个简单的服务器-客户端socket编程中服务器端的示意图,一共用到了4个socket编程专用的函数。它们分别是socket()
, bind()
, listen()
, accept()
。
socket()
函数用于生成一个socket描述符,根据实际需要,我们可以让socket()
生成适合不同网络的socket描述符。比如生成IPV4, Stream并且是TCP的socket描述符和IPV4, 报文并且是UDP的socket描述符:1
2int sock_udp = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
int sock_tcp = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);有了socket描述符后,我们就需要将IP, 端口等的信息绑定到socket描述符上。我们通过调用
bind()
函数来实现这个功能。bind()用于绑定自己的接受端IP和端口,一般只绑定服务器端,不绑定客户端。服务器端如果有多个IP地址(比如多个网卡),那么可以指定某个网卡的IP接收数据;我们也可以用INADDR_ANY
作为IP地址,内核会在连接时自动选择一个IP地址用于连接。要使用bind()
,我们首先需要一个已经写好IP和端口的sockaddr_in
和它的地址,然后需要服务器端用于监听的socket描述符号,下面是bind()
的函数原型。1
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
绑定IP和端口后,服务器端需要调用
listen()
对socket描述符进行监听。我们这里要设置一个最大连接数,这个最大连接数包括已经完成连接的和未完成连接的socket。已完成的连接是指服务器端已经accept()
的连接,之前的连接都是未完成的连接。接下来就是连接的最后一步了,调用
accept()
函数来接受客户端发送的连接。accept()
函数默认情况下会阻塞进程,直到有连接到达。该函数原型如下:1
int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);
我们需要提供监听socket描述符、用于存放客户端IP, 端口信息的空间的地址以及该空间的大小。这个函数对于每个成功的接受,都会生成一个新的socket描述符,之后和客户端进行通信都是通过这个新生成的socket描述符。
客户端
客户端和服务器端类似,图6是客户端socket通信的示意图。我们可以看到,和服务器端相比,少了bind()
, listen()
和accept()
,但多了connect()
。
connect()
函数原型如下:
1 | int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); |
这里sockfd
是客户端socket()
返回的socket描述符,sockaddr
存放的是服务器端的IP和端口,addrlen
是sockaddr
存放数据的大小,这些数据都会复制到内核中的buffer中。
这种传地址、数据长度给函数(再给内核)的方法也叫“值-结果”,在Linux网络编程中,使用这种方式的函数还有很多,下面是写入内核的示意图:
Socket多进程基础
fork和exec函数
这部分属于多进程编程的范畴,fork()
简单来说就是父进程复制一个进程作为子进程,这个子进程和父进程属于不同的内存空间,但他们的内容是一样的。现在Linux中并不是直接复制,而是只读的部分还是在一起,但要写的部分放在自己的内存空间。这么做主要是为了减少内存空间和时间的开销。
exec()
函数用于覆盖父进程复制的空间,也就是用子进程要运行的代码来替代父进程的代码。在socket编程中,服务器端往往不用额外的代码,只需要和父进程一样的代码,因此可以不用exec()
。
示例代码
1 | for ( ; ; ) { |
这是一段简单的多进程socket服务器端的代码,我们可以看到子进程返回后,第一件事情就是关闭listenfd
,之后父进程会关闭connfd
。这里要注意,在关闭socket描述符时,并不是调用close()
就结束该描述符对应的连接。每个描述符有一个counter,只有counter为0时,该连接才真正的关闭。这里经过fork()
后,listenfd
的counter变成了2,因此关闭listenfd
后,该socket并没有结束通信。图8展示了这个过程:
fork()
后,connfd
和listenfd
都有两个;当通信结束时,两个connfd
都会被关闭,从而断开连接。而listenfd
最终会保持一个,因此这个socket不会断开连接。