因特网上的每台计算机都有一个唯一的IP地址,如果一台主机上的数据要传输到另一台主机,那么对端主机的IP地址就应该作为该数据传输时的目的IP地址。但仅仅知道目的IP地址是不够的,当对端主机收到数据后,对端还需要对该主机作出相应,因此对端主机也需要发送数据给该主机,此时对端主机就需要知道该主机的IP地址。因此一个传输的数据中应该涵盖其IP地址和目的IP地址,目的IP地址表明该数据传输的目的,源IP地址作为对端主机相应时的目的IP地址。
在数据传输之前,会先自顶向下贯穿网络贯穿数据协议栈完成数据的封装,其中在网络层封装的IP报头当中就涵盖了就涵盖了源IP地址和目的IP地址。
大部分数据的传输都是跨局域网的,数据在传输过程中会经过若干个路由器,最终才能到达对端主机。
源MAC地址和目的MAC地址是包含在链路层的报头当中的,而MAC地址实际只在当前局域网内有效。因此当数据跨网络到达另一个局域网时,也就是数据到达路由器时,路由器会将该数据当中链路层的报头去掉,然后再封装一个报头,此时该数据的源MAC地址和目的MAC地址就发生了变化。
因此数据在传输的过程中是有两套地址:
套接字通信的本质
首先我们需要理解的是,两个主机之间进行通信,不仅仅是为了将数据发给对端主机,而是为了访问对端主机上的某个服务。比如我们在使用百度搜索引擎时,我们不仅仅将我们的请求发给对端服务器,而是想访问对端服务器上部署的百度相关的搜索服务。
我们可以通过IP地址和MAC地址将数据发给对端主机,但实际上我们是要发给对端数据上的某个进程,此外,数据的发送者也不是主机,而是主机上的某个进程。比如,当我们用浏览器访问网站时,实际也就是浏览器进程向对端服务器进程发起请求。
也就是说,套接字通信的本质就是两个进程之间在进行通信,只不过这里是跨网络的进程间通信。比如我们刷抖音的时候,其实就是我们手机上的抖音进程在于对端服务器主机上的抖音服务器进程在进行通信。
因此进程间通信的方式除了管道,共享内存,消息队列,信号量之外,还有套接字,只不过套接字是跨网络的。
认识端口号
实际在两台主机上,可能会存在许多进行网络通信的进程,当数据发送到该主机之后,需要将数据交给指定的进程。而当进程处理完数据之后还要对发送端进行相应,因此对端主机也需要知道是发送端上哪一个进程向它发起请求。
端口号(port)的作用就是标识一台主机上的一个进程。
由于IP地址可以标识公网内的某一台主机,而端口号又可以标识主机上的某一个进程,因此用IP地址+端口号就可以标识网络上某一台主机的某一进程了。
当数据在传输层进行封装时,就会添加上对应源端口号和目的端口号的信息,在网络层又会添加上对应源IP地址和目的IP地址,这样一来,通过源端口号和源IP地址就能在网络上标识发送数据的进程,通过目的端口号和目的ID地址就可以在网络上标识接收数据的进程,这就实现了跨网络的进程间通信。
注意:因为端口号是属于某台主机的,所以端口号可以在两台不同的主机当中重复,但是在同一台主机上进行网络通信的端口号不能重复。此外,一个进程可以绑定多个端口号,但是一个端口号只能被一个进程绑定。
理解“套接字”这个名字
套接字源自于单词socket,套接字编程实际上就是socket编程。socket在英文上也有插座的意思,在进行网络通信的时候,客户端就相当于接口,服务端就相当于一个插座,但服务端上可能会有许多不同的服务进程(多个插孔),因此当我们在访问服务时需要指明服务进程端口号(对应规则的插孔),才能得到对应服务进程的服务。
而套接字,我们也可以姑且理解为一套用于连接的文字。
端口号(port)的作用是表示主机上的某个进程,进程PID的作用也是表示主机上的某个进程,那为什么在进行网络通信的时候不直接使用PID来代替port呢?
进程PID是用来表示系统内进程的唯一性的,属于系统级别的概念。而端口号是在网络通信时标识进程的唯一性的,属于网络级别的概念。
每个人都有自己的身份证号,但是为了方便管理,到了学习会有学号,到了公司会有工号。网络通信使用port同理。
且在网络中使用port,在系统中使用PID,可以使网络和系统很好地解耦。
底层是如何通过port找到进程的? 实际底层采用哈希的方式建立了端口号和进程PID或PCB之间的映射关系,当底层拿到端口号时就可以直接执行对应的哈希算法,然后就能找到该端口号对应的进程。
当我们使用系统调用接口实现网络数据通信时,不得不面对的协议层就是传输层,而传输层最经典的两种协议就是TCP协议和UDP协议。
TCP协议
TCP协议叫做传输控制协议(Transmission Control Protocol),TCP是一种面向连接的,可靠的,基于字节流的传输层控制协议。
TCP是面向连接的,如果两台主机之间要进行数据传输,必须先建立连接,在连接建立完成之后才可以进行数据传输。其次,TCP是保证可靠的协议,数据在传输过程中如果出现了丢包、乱序等情况,TCP都有对应的解决方案。
UDP协议
UDP协议叫做用户数据报协议(User Datagram Protocol),UDP是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议。
使用UDP协议进行通信时无需建立连接,如果两台主机之间想要进行数据传输,那么直接将数据发给对端主机就行了,但这也就意味着UDP协议是不可靠的,数据在传输过程中如果出现了丢包、乱序等情况,UDP协议本身是不知道的。
既然UDP协议是不可靠的,那为什么还要有UDP协议的存在?
TCP协议是一种可靠的传输协议,使用TCP协议可以保证数据传输时的可靠性,而UDP协议是一种不可靠的传输协议,它的存在有什么意义呢?
首先,可靠是需要我们更多的工作的,TCP协议虽然是一种可靠的传输协议,但这意味着TCP协议在底层一定要做更多的工作,因此TCP协议底层的实现是比较复杂的,我们不能只看到TCP协议面向连接这个优点。还要看到TCP协议对应的缺点。
同样的,UDP协议虽然是一种不可靠的传输协议,但这一定意味着UDP协议在底层不需要做过多的工作,因此UDP协议底层的实现一定比TCP协议要简单的多,UDP协议虽然不可靠,但是它能把数据快速地发给对方,虽然数据在传输的过程中可能会出错。
编写网络通信代码时具体采用TCP协议还是UDP协议,完全取决于上层的应用场景。如果应用场景严格要求数据在传输过程中的可靠性,我们就必须采用TCP协议,如果应用场景允许数据在传输出现少量丢包,那么我们肯定优先选择UDP协议,因为UDP协议足够简单。
一些网站在设计网络通信算法时,会同时采用TCP协议和UDP协议,当网络流畅时就使用UDP协议进行数据传输,当网络不好时就使用TCP协议进行数据传输,此时就可以动态地调整后台通信算法。
网络中的大小端问题
计算机在存储数据时是有大小端的概念的:
如果编写的程序只在本地机器上运行,那么是不需要考虑大小端问题的,因为同一台机器上采用的存储方式都是一样的,要么采用大端存储模式,要么采用小端存储模式。但如果设计网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的。
例如,现在两台主机在进行网络通信,其中发送端是小端机,而接收端是大端机。发送端将发送缓冲区内的数据按内存地址从低到高的顺序发出后,接收端从网络中获取数据一次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存的。
但由于发送端和接收端采用的分别是小端存储和大端存储,此时对于内存地址从低到高为 44332211 的序列,发送端按小端的方式识别出来的是 0x11223344,但是接收端按大端的方式识别的是 0x44332211,此时接收端接收到的数据就与发送端原本想要发送的数据就不一样了,这就是由于大小端的偏差导致数据识别出现了错误。
由于我们不能保证通信双方存储数据的方式是一样的,因此网络当中传输的数据必须考虑大小端问题。因此TCP/IP协议规定,网络数据流采用大端字节序,即低地址高字节。无论是大端机还是小端机,都必须按照TCP/IP协议当中规定的网络字节序来发送和接收数据。
主机字节序和网络字节序直接的转换接口
为了使网络程序具有可移植性,使同样的C代码在大端和小端机器上编译后都能正常运行,系统提供了四个函数,可以通过调用以下库函数实现网络字节序和主机字节序直之间的转换:
创建套接字:(TCP/UDP,客户端+服务端)
绑定端口号:(TCP/UDP,服务器)
监听套接字:(TCP,服务器)
接收请求:(TCP,服务器)
建立连接:(TCP,客户端)
sockaddr结构的出现
套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。在进行跨网络通信时我们需要传递的端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in结构体和sockaddr_un结构体,其中sockaddr_in结构体是用于跨网络通信的,而sockaddr_un结构体是用于本地通信的。
为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockaddr结构体,该结构体与sockaddr_in结构体和sockaddr_un的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族。
此时当我们在传参时,就不用传入sockaddr_in或者sockaddr_un这样的结构体,而统一传入sockaddr这样的结构体。在设置参数时就可以通过设置协议家族这个字段,来表明我们是要进行网络通信还是本地通信,在这些API内部就可以提取sockaddr结构体的16位头部进行识别,进而得出我们是要进行网络通信还是本地通信,然后执行对应的操作。这样我们通过sockaddr结构,就将套接字网络通信和本地通信的参数类型实现了统一。
注意:实际我们在进行网络通信的时候,定义的还是sockaddr_in这样的结构体,只不过在传参时需要将该结构体的地址类型进行强转为==sockaddr*==罢了。
为什么不用 void* 代替 struct sockaddr* 类型呢
我们可以将这些函数接口形参的struct sockaddr参数改为void,此时在函数背部也可以直接提取头部的16个比特位进行识别,最终也能够判断是需要进行网络通信还是本地通信,那为什么还要设计出sockaddr这样的结构呢?
实际在设计这一套网络接口的时候C语言还不支持void* ,于是就设计出了sockaddr这样的解决方案。