我们知道本地的进程间通讯(IPC)有很多种方式,总结后可以分为以下四类:
但是这些都不是这篇博客的重点,这篇博客重点在于网络中是如何通讯的,首先需要解决的是如何表示一个进程,在本地中我们可以使用PID来标识一个进程,但是在网络中PID是没有用的。幸好TCP/IP协议帮我们解决了这个问题,网络层的“IP地址”可以唯一标识一个主机,而传输层的“协议+端口”可以帮我们唯一标识主机中的一个应用程序(进程)。
这个样子就可以使用三叉戟(IP地址,协议,端口号)就可以标识网络中的进程了,网络中的所有通讯就可以利用这个三叉戟进行了。就目前而言,几乎所有的应用程序都是采用的socket,而现在又是网络时代,网络中的通信是无处不在的,这也就是为什么说“ 一切皆socket ”。
上面我们已经知道网络中的进程是用过socket来通讯的,那什么是socket呢?
socket起源于UNIX,而UNIX/Linux的一大特点就是“一切皆文件”,都可以用“打开open->读写write/read->close”模式来操作,那么我觉得可以将socket看作是一个特殊的文件,一些socket函数就是对其进行的操作。
①第一步:首先要创建一个socket套接字(其本质为一个文件描述符):
//相当于“买手机”
这个socket系统调用可以用于创建一个socket:
代码:
②第二步:命名socket(将一个socket与socket地址绑定称为给socket命名)
//相当于“注册手机卡后给手机装卡”
这一步主要是由于我们创建socket的时候,只是给它指定了协议族,但是并未指定使用该地址族中的哪个具体的socket地址。
我们知道,在服务器中,通常要命名socket,因为只有命名后客户端才知道该如何连接它。
但是在客户端中,则通常不需要命名socket,而是采取匿名方式,即使用操作系统自动分配的socket地址。命名socket的系统调用是bind,其定义如下:
/*以下就是用来对应第二步标题的办卡装卡*/
bind系统调用第二个参数是一个struct sockaddr结构体的指针,存放的是地址族协议和socket地址值,但是由于这个结构体中存放socket地址值的变量太小,根本无法容纳多数协议族的地址值。所以Linux系统专门定义了新的专用socket地址结构体用来存储。
由于我们使用的是TCP/IP协议的IPV4,所以这里使用对应的专用socket地址结构体:
注意:所有专用socket地址类型的变量在实际使用时,都需要转化为通用socket地址类型sockaddr(强制转换即可),因为所有socket编程接口使用的地址参数的类型都是sockaddr。
那么为什么我们要将端口号以及IP地址转换为网络字节序才能使用呢?
答:如果在两台使用不同字节序的主机之间进行信息传递时,由于接收方不知道该使用大端还是小端去解析这个数据,必然会出现一系列错误。所以为了避免这种情况的发生,我们统一将要发送的数据转化为大端字节序(网络字节序)数据后,再进行发送,这样接收方就可以根据自身情况选择具体的大小端进行解析了。
需要指出的是,哪怕是一台主机上的两个进程(比如一个由C语言编写,另一个由java编写)通讯,也要考虑字节序的问题(java虚拟机采用的是大端字节序)。
所以在这里,Linux提供了4个函数来完成(主机字节序)和(网络字节序)之间的转换:
他们的含义很明确,比如htonl表示“host to network long”,即将长整形的主机字节序数据转换为网络字节序数据。这4个函数中,长整形数据通常用来转换IP地址,短整型数据用来转换端口号。
IP地址转换函数:
通常,人们习惯用可读性好的字符串来表示IP地址,比如用点分十进制字符串表示IPV4地址,以及用十六进制字符串表示IPV6,但编程中我们需要将其转化为整数(二进制)才能使用,而记录日志时则刚好相反,我们要把整数表示的IP地址转换为可读的字符串。
下面的3个函数可用于 (点分十进制字符串表示的IPV4地址)和(用网络字节序整数表示的IPV4地址)之间的转换:
代码清单5-2可以证明:
创建socket地址的代码(上面标题提到的注册手机卡):
/*而这里就是办好手机卡之后,用bind系统调用来给手机装卡的*/
代码:
③第三步:监听socket,即创建一个监听队列
//相当于“开启手机”
socket被命名之后,还不能马上接收客户连接,我们需要使用如下的系统调用来创建一个监听队列以存放待处理的客户连接:
listen系统调用成功时返回0,失败时则返回 -1,并设置errno。
需要注意的是这里的最大长度指的是处于完全连接状态(完成了三次握手)的socket上限,不算正在与服务器对话的客户端(这个连接已经从监听队列中取出来了)。
如果backlog传入5,则内核维护的监听队列的最大长度为6(size + 1)。那么第七个连接服务器的客户端进程则处于SYN_RECV状态(半连接状状态),指的是三次握手,已完成了两次,再进一步接收到客户端的ACK(确定信息)就进入了客户端ESTABLISHED(已完成连接)状态了。
也就是客户端发送了SYN请求连接信息,服务器端接收到了,也发送了回馈信息,但是这个监听队列没有多余的空间了,所以只能处于SYN_RECV状态,如果过了一点点时间,系统发现第七个连接还是处于半连接状态,则自动将其断掉。
代码:
④第四步:接受连接(accept系统调用从listen监听队列中接受一个连接)
//相当于“接听电话”
代码:这里服务器与一个客户端结束交互时,要接着与监听队列中下一个客户端进行交互,所以这里要写到while循环中。
问题:
代码就不截图了,参考书:Linux高性能服务器编程中第五章内容
结论:accept只是从监听队列中取出连接,而不论连接处于何种状态(如ESTABLISHED状态以及CLOSE_WAIT状态),更不关心任何网络状况的变化。
⑤第五步:TCP数据读写(通过recv/send收发数据)
//相当于“进行通话”
对文件的读写操作read/write同样适用于socket。但是socket编程接口提供了几个专门用于socket数据读写的系统调用,他们增加了对数据读写的控制。
其中用于TCP流数据读写的系统调用是:
flags参数的可选值见下表:
代码:因为接收数据时有可能一次也读不完,所以要写到while循环内,进行多次读取。
第六步:关闭连接(服务器端要关闭sockfd描述符以及accept返回的描述符)
//相当于“挂电话以及关机”
关闭一个连接实际上就是关闭该连接对应的socket,这可以通过如下关闭普通文件描述符的系统调用来完成:
参数fd是待关闭的socket。不过,close系统调用并非是立即关闭一个连接,而是将fd的引用计数 -1,只有当fd的引用计数为0时,才真正关闭连接。多进程程序中,一次fork系统调用默认将父进程中打开的socket的引用计数 +1,因此我们必须在父进程和子进程中都对该socket执行close调用才能将此连接关闭。
如果无论如何都要立即终止连接(而不是将socket的引用计数 -1),则可以使用如下的shutdown系统调用(相对于close来说,它是专门为网络编程设计的):
sockfd参数是待关闭的socket,howto参数决定了shutdown的行为,它可取表中的某个值:
由此可见,shutdown可以分别关闭socket上的读和写,或者都关闭。而close在关闭连接时只能将socket上的读和写同时关闭。
shutdown成功时返回0,失败则返回 -1,并设置errno。
具体实现与服务器端大同小异,不过bind这一步可有可无,这是因为服务器端我们通常要命名socket,因为只有命名后客户端才知道该如何连接它。但是在客户端中,则通常不需要命名socket,而是采取匿名方式,即使用操作系统自动分配的socket地址。
就像给110打电话,110作为服务器端,你手机装没装手机卡,都可以直接给110打紧急急救电话,所以这里采用匿名方式,即使用操作系统自动分配的socket地址。
第一步:创建socket,与TCP大致一致
第二步:命名socket,一般不写这步,由操作系统自动给分配socket地址。
第三步:主动发起连接,connect();
如果说服务器端listen调用来被动接受连接,那么客户端需要通过如下的系统调用来主动与服务器建立连接:
第四步:TCP数据读写(通过recv/send收发数据),与TCP大致一致
第五步:关闭连接(因为客户端没有accept这一步,所以只需要关闭sockfd即可),与TCP大致一致