预备知识
源IP地址和目的IP地址
IP地址在上一篇博客中也介绍过,它是用来标识网络中不同主机的地址。两台主机进行通信时,发送方需要知道自己往哪一台主机发送,这就需要知道接受方主机的的IP地址,也就是目的IP地址,因为两台主机是要进行通信的,所以接收方需要给发送方进行一个响应,这时接收方主机就需要知道发送方主机的IP地址,也就是源IP地址。有了这两个地址,两台主机才能够找到对端主机。
- 源IP地址: 发送方主机的IP地址,保证响应主机“往哪放”
- 目的IP地址: 接收方主机的IP地址,保证发送方主机“往哪发”
端口号
端口号是属于传输层协议的一个概念,它是一个16位的整数
,用来标识主机上的某一个进程
注意:一个端口号只能被一个进程占用
在上面说过,公网IP地址是用来标识全网内唯一的一台主机,端口号又是用来标识一台主机上的唯一一个进程,所以IP地址+端口号 就可以标识全网内唯一一个进程
端口号和进程ID: 二者都是用来唯一标识某一个进程。它们的区别和联系是:
一台主机上可以存在大量的进程,但不是所有的进程都需要对外进行网络请求。任何的网络服务和客户端进程通信,如果要进行正常的数据通信,必须要用端口号来唯一标识自身的进程,只有需要进行网络请求的进程才需要用端口号来表示自身的唯一性,所以说端口号更多的是网络级的概念。进程pid可以用来标识所有进程的唯一性,是操作系统层面的概念。二者是不同层面表示进程唯一性的机制。
源端口号和目的端口号: 两台主机进行通信,只有对端主机的IP地址只能够帮我们找到对端的主机,但是我们还需要找到对端提供服务的进程,这个进程可以通过对端进程绑定的端口号找到,也就是目的端口号,同样地,对端主机也需要给发送方一个响应,通过源IP地址找到发送方的那一台主机,找到主机还是不够的,还需要找到对端主机是哪一个进程发起了请求,响应方需要通过发起请求的进程绑定的端口号找到该进程,也就是源端口号,然后就可以进行响应。
- 源端口号: 发送方主机的服务进程绑定的端口号,保证接收方能够找到对应的服务
- 目的端口号: 接收方主机的服务进程绑定的端口号,保证发送方能够找到对应的服务
socket通信的本质: 跨网络的进程间通信。从上面可以看出,网络通信就是两台主机上的进程在进行通信。
注意:一个局域网才拥有一个独立的IP,IP地址只能定位到一个局域网,无法定位到具体哪台设备,要想定位到哪台设备,就必须知道这个设备的MAC地址,IP地址解决的是数据在外网(因特网,互联网)的传输问题,而MAC解决的是数据在内网(局域网)的传输问题,但是MAC地址不需要我们组包,链路层底层协议栈就会帮你组好。
Socket套接字
Socket 是在应用层和传输层之间的一个抽象层,它把 TCP/IP 层复杂的操作抽象为几个简单的接口,供应用层调用实现进程在网络中的通信。Socket 起源于 UNIX,在 UNIX 一切皆文件的思想下,进程间通信就被冠名为文件描述符(file descriptor)
,Socket 是一种“打开—读/写—关闭”模式的实现,服务器和客户端各自维护一个“文件”,在建立连接打开后,可以向文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。
在网络通信中,套接字一定是成对出现的。一端的发送缓冲区对应对端的接收缓冲区。
重点:套接字本质上也是一个文件描述符,指向的是一个“网络文件”。普通文件的文件缓冲区对应的是磁盘,数据先写入文件缓冲区,再刷新到磁盘,“网络文件”的文件缓冲区对应的是网卡,它会把文件缓冲区的数据刷新到网卡,然后发送到网络中。 创建一个套接字做的工作就是打开一个文件,接下来就是要将该文件和网络关联起来,这就是绑定的操作,完成了绑定,文件缓冲区的数据才知道往哪刷新。
网络字节序
我们已经知道,内存中的多字节数据相对于内存地址有着大端和小端的区分。同样,网络数据流同样有大端和小端的区分。
思考一下,如何定义网络数据流的地址呢?
发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节一次保存在接收缓冲区中,也就是按照内存地址从低到高的顺序保存。
网络数据流的地址应该这样规定:先发出的数据是低地址,后发出的数据是高地址。
- 大端字节序: 高位存放在低地址,低位存放在高地址
- 小端字节序: 低位存放在低地址,高位存放在高地址
如果双方主机的数据在内存存储的字节序不同,就会造成接收方收到的数据出现偏差,所以为了解决这个问题,又有了下面的规定:
- TCP/IP协议规定,网络数据流采用
大端字节序
,不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据 - 所以如果发送的主机是小端机,就需要把要发送的数据先转为大端,再进行发送,如果是大端,就可以直接进行发送。
为了方便我们进行网络程序的代码编写,有下面几个API提供给我们用来做网络字节序和主机字节序的转换,如下:
说明:
- h代表的是host,n代表的是network,s代表的是16位的短整型,l代表的是32位长整形
- 如果主机是小端字节序,函数会对参数进行处理,进行大小端转换
- 如果主机是大端字节序,函数不会对这些参数处理,直接返回
注意:在编程中我们需要自行进行大小端转化的就只有三个:ip地址,传输数据和端口,这两个数据需要我们进行大端的转化,其他的在计算机组包的时候会自动给我们转化。
Socket常见的API
常用的有以下几个,后面会具体的介绍
Sockaddr结构体
- sockaddr_in用来进行网络通信,sockaddr_un结构体用来进行本地通信
- sockaddr_in结构体存储了协议家族,端口号,IP等信息,网络通信时可以通过这个结构体把自己的信息发送给对方,也可以通过这个结构体获取远端的这些信息
- 可以看出,这三个结构体的前16位时一样的,代表的是协议家族,可以根据这个参数判断需要进行哪种通信(本地和跨网络)
- IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型, 16位端口号和32位IP地址;而IPv6地址用sockaddr_in6结构体来表示
- IPv4、 IPv6地址类型分别定义为常数AF_INET、 AF_INET6。这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容
- socket API可以都用struct sockaddr *类型表示,在使用的时候需要强制转化成sockaddr;这样的好处是程序的通用性,可以接收IPv4,IPv6,以及UNIX Domain Socket各种类型的sockaddr结构体指针为参数
注意:IPv4和IPv6分别有自己对应的结构体,但是为了统一,我们不知道用户要传的是ipv4还是ipv6,所以就类似于我们不知道用户要输入char还是int类型,此时我们就会写成void *类型;同理,为了统一,这里有个通用的套接字结构体struct sockaddr,将结构体IPv4和IPv6转化成sockaddr类型就可以了,struct sockaddr会根据ipv4和ipv6结构体的前几位判断需要传输的协议类型是IPv4还是IPv6。
sockaddr_in的结构: 因为我们主要用到网络通信,所以这里主要介绍这个结构体,打开/usr/include/linux/in.h
sin_family
代表的是地址类型,我们主要用的是AF_INET
,sin_port
代表的是端口号,sin_addr
代表的是网络地址,也就是IP地址,用了一个结构体struct in_addr
进行描述
这里填充的就是IPv4的地址,一个32位的整数
地址转换函数
IP地址可以用点分十进制的字符串(例如127.0.0.1),这里涉及到字符串和32位整网络的大端数据之间的相互转换。下面价绍二者之间转化的库函数:
注意: inet_ntoa这个函数内部会申请一块空间,保存转换后的IP的结果,这块空间被放在静态存储区,不需要我们手动释放。且第二次调用该函数,会把结果放到上一次的静态存储区中,所以会覆盖上一次调用该函数的结果,是线程不安全的。inet_ntop这个函数是由调用者自己提供一个缓冲区保存结果,是线程安全的。
TCP通信的基本流程
服务端:
看上图:给大家讲解一下服务端的流程
1.首先服务端会调用socket函数创建一个套接字,上面说过了套接字是一个特殊的”网络文件“,存在读写缓冲区
2.调用bind函数将这个套接字绑定ip和端口号,注意此时的ip和端口号都是服务器自己的端口号和ip,因为服务器是被动的连接,生成的是监听套接字,监听的是客户端发来的要连接的服务器的ip和端口号,监听套接字会查看自己绑定的ip和端口号和客户端发来的要连接的服务器的ip和端口号是否和自己一样,才能决定是否接受连接
3.调用listen函数,使得套接字变成一个被动的监听套接字,使已绑定的套接字等待监听客户端的连接请求,并设置服务器同时可以连接的数量(已连接队列和未连接队列),当监听到客户端发来的ip和端口号与未连接队列中的套接字吻合时,就把客户端发来的套接字信息放到已连接队列当中
4.调用accept函数,如果listen已连接队列中没有请求的话,该函数会阻塞,直到连接队列发来信息,该函数的第一个参数用来标识服务端套接字,第二个参数用来保存客户端套接字,实际上accept函数指定了服务器接收客户端的连接,并将客户端的套接字信息(ip和端口)保存了下来,因为当服务器给客户端发送数据的时候需要知道客户端的ip和端口
- 值得注意的是,accept会生成一个新的套接字链接,这个套接字已经连接了服务器和客户端,原来的监听套接字和客户端的连接就会断开,以后的通信就是新的连接套接字和客户端进行通信
- 为什么要建立一个新的套接字呢?因为监听套接字有自己的工作,还需要监听其他来访的客户端的连接请求,如果用监听套接字和客户端进行通信,那么其他客户端想要连接该服务器的端口就不会成功,影响很大
5.基于新产生的 socket 调用 send 或 recv 函数开始与客户端进行数据交流
6.通信结束后,调用 close 函数关闭侦听 socket
客户端:
TCP相关的套接字API
TCP是面向连接的,不同于UDP,TCP需要创建好套接字并且绑定端口号,绑定好之后,还需要进行监听,等待并获取连接。
- listen
- accept
- connect
思考一下:不知道大家是否对accept
会有疑惑,已经通过socket
创建好了一个套接字,accept又返回了一个套接字,这两个套接字有什么区别吗?UDP只有一个套接字就可以进行通信了,而TCP还需要这么多个,这是为什么?
基于TCP协议的套接字协议
服务器
整体框架
封装一个类,来描述tcp服务端,成员变量包含端口号和监听套接字两个即可,ip像udp服务端一样,绑定INADDR_ANY
,构造函数根据传参初始化port,析构的时候关闭监听套接字即可
服务端的初始化
创建套接字
创建套接字用到的是socket这个接口,具体介绍如下:
代码如下:
绑定端口号
绑定端口号需要用到bind这个接口:
这里端口号我们填充一个8080,协议家族填充的还是AF_INET,这里IP绑定一个字段叫INADDR_ANY(通配地址),值为0,表示取消对单个IP的绑定,服务器端有多个IP,如果指明绑定那个IP,那么服务端只能够从这个IP获取数据,如果绑定INADDR_ANY,那么服务端可以接受来自本主机任意IP对该端口号发送过来的数据 填充好了这个结构体,我们需要它进行强转为struct sockaddr
注意: 因为数据是要发送到网络中,所以要将主机序列的端口号转为网络序列的端口号
绑定端口号,需要填充struct sockaddr_in
这个结构体,里面有协议家族,端口号和IP,端口号根据用户传参进行填写,IP直接绑定INADDR_ANY
,具体代码如下:
将套接字设置为监听状态
这里就需要用的listen
这个接口,让套接字处于监听状态,然后可以去监听连接的到来代码也很简单,具体如下:
循环获取连接
听套接字通过accept获取连接,一次获取连接失败不要直接将服务端关闭,而是重新去获取连接就好,因为获取一个连接失败而直接关闭服务端,带来的损失是很大的,所以只需要重新获取连接即可,返回的用于通信套接字记录下来,进行通信,然后可以用多种方式为各种连接连接提供服务,具体服务方式后面细说,先看获取连接的一部分代码:
客户端
整体框架
和服务端一样,封装一个类描述,类成员有服务端ip、服务端绑定的端口号以及自身套接字,代码如下:
客户端初始化
客户端的初始化只需要创建套接字即可,不需要绑定端口号,发起连接请求的时候,会自动给客户端分配一个端口号。创建套接字和服务端是一样的,代码如下:
客户端启动
发起连接请求
使用connect
函数,想服务端发起连接请求,注意,调用这个函数之前,需要先填充好服务端的信息,有协议家族、端口号和IP,请求连接失败直接退出进程,重新启动进程即可,连接成功之后就可以像服务端发起各自的服务请求(后面介绍),代码如下:
发起服务请求
请求很简单,只需要让用户输入字符串请求,然后将请求通过write
(send也可以)发送过去,然后创建一个缓冲区,通过read
(recv也可以)读取服务端的响应,这里需要着重介绍一下read
的返回值
- 大于0:实际读取的字节数
- 等于0:读到了文件末尾,说明对端关闭,用在服务端就是客户端关闭,用在客户端就是服务端关闭了,客户端可以直接退出
- 小于0:说明读取失败
不同版本的服务端服务代码
多进程版本
思路: 为了给不同的连接提供服务,所以我们需要让父进程去不断获取连接,获取连接后,让父进程创建一个子进程去为这个获取到的连接提供服务,那么问题来了,子进程去服务连接,父进程是否需要等待子进程?按常理来说,是需要的,如果不等待的话,子进程退出,子进程的资源就没有人回收,就变成僵尸进程了,如果父进程等待子进程的话,父进程就需要阻塞在哪,无法去获取到新的连接,这也是不完全可行的,所以就有了一下两种解决方案:
- 1.通过注册SIGCHLD(子进程退出会想父进程发起该信号)信号,把它的处理信号的方式改成SIG_IGN(忽略),此时子进程退出就会自动清理资源不会产生僵尸进程,也不会通知父进程,这种方法比较推荐,也比较简单粗暴
- 2.通过创建子进程,子进程创建孙子进程,子进程直接退出,让1号进程领养孙子进程,这样父进程只需要等很短的时间就可以回收子进程的资源,这样父进程可以继续去获取连接,孙子进程给连接提供服务即可
方法一代码编写:
完整版代码:
运行结果如下:
注意: 方法二中,父进程创建好子进程之后,子进程可以将监听套接字关闭,此时该套接字对子进程来说是没有用的,当然也可以不用关闭,没有多大的浪费。但父进程关闭掉服务sock是有必要的,因为此时父进程不需要维护这些套接字了,孙子进程维护即可,如果不关闭,且有很多客户端向服务端发起请求,那么父进程这边就要维护很多不必要的套接字,让父进程的文件描述符不够用,造成文件描述符泄漏,所以父进程关闭服务套接字是必须的。 方法二代码编写:
小伙伴们可以动手运行一下哦~
多线程版本
思路: 通过创建一个线程为客户端提供服务,创建好的线程之间进行线程分离,这样主线程就不需要等待其它线程了 方法: 让启动函数执行服务的代码,其中最后一个参数可以传一个类过去,这个类包含了,客户端端口号和套接字信息,如下:
注意: 这里为了不让thread_run
多一个this
指针这个参数,所以用static
修饰该函数,就没有this
指针这个参数了,为了让创建出来的线程线程就可以调用该Service
函数,这里将Service
函数也用static
修饰
线程池版本
由于还没有介绍线程池的相关知识,下一章博客将会更新线程池的知识和线程池版本的服务器代码
标签:
留言评论