TCP服务器
Tcp服务器和Udp编写服务器基本步骤都是相同的,都是按照固定模式来的
我们就不再赘述,直接先呈现具体代码
接下来,与Udp不同的是,Tcp是面向连接的服务器,在正式通信之前,一定得随时随地的等待别人连接。也就是说通信之前,得先建立连接,那么怎么知道别人发来连接来呢?
listen函数
等待别人连接的状态,我们称之为listen状态,也就是监听状态,在这种状态下,我们会监听对应的socket,由此来知道是否有人与我们建立了连接,如果一直没有人连接,Tcp服务器就会阻塞,一直等待。
listen函数,第一个参数是监听的socket套接字,第二个参数表示的是全连接的长度,这个我们后面会进行解释。
有连接了,我们怎么获取呢?
这就需要函数accept。
accept函数
accept函数的返回值是一个套接字也就是一个文件描述符。它的第一个参数是套接字,后面两个参数就和recvfrom函数一样的两个参数,这两个参数是输出型参数,用于让服务器获取到连接者的信息。
注意:如果一直没有人连接的话,accept就会阻塞,直到有人连接。
那么这里可能会有一个疑问,为什么TCP通信,accept这里又返回一个套接字,明明都已经有了一个套接字了,为什么不直接用这个呢?
这里讲个小故事,你就明白了。从前啊有个餐馆叫好再来鱼庄,店里有个员工叫张三,它呢,主要负责在餐馆外面揽客,揽到客人后,就引到餐厅里,再喊一个员工,李四来接待,协助点菜,自己则又继续到餐厅外继续揽客。
这里监听用的套接字,也即是accept传入的套接字,就相当于张三,只负责监听,不提供其他服务。而accept返回到套接字则负责服务。
由于这里多出来一个套接字,为了方便区分,把原来的套接字改为_listensock,同时给Init部分加上打印提示行表示申请资源成功;
接下来,我们来将Run函数完善一下
我们目前还没有写客户端,怎么测试服务器呢? 可以使用telnet进行指定服务的远程登陆,说是登陆,其实就是连接,telnet的底层就tcp协议
我们这里就发现,服务器是可以接受我们telnet发起的连接的
TCP这里能不能直接绑定云服务器公网IP呢? 不能
我们可以简单提供一个Service服务,进行测试
由于read和write是面向字节流的,所以TCP通信不需要另外设置接口,直接用write和read就可以了。
这里大家可能会有一个疑问,那就是网络通信,规定使用大端字节序,为什么我们在udp,tcp中发送的消息,以及接收消息,不需要转为网络序列呢?
这是因为你是用的接口像sendto,recvfrom,read,write 。这些接口默认就把套接字正常通行的内容,由主机序列转成网络序列了。
编译运行,使用telnet指令进行连接,就可以进行通信了。
TCP客户端
客户端先要获取服务器的服务,首先得建立连接。怎么建立连接? 使用connect函数。
connect函数
connect 的第一个参数是套接字,后两个参数说明向哪个服务器获取服务。
需要注意的是:
client 不需要bind UDP中 sendto 自动帮我们bind了,前面套路都是一样的,不过这里需要connet向服务器发起一个连接,这个时候connect会自动帮我们bind
编译运行,就能让客户端和服务器通信起来了。
TCP 服务器与客户端之间的通信
与UDP不同的是
TCP服务器需要listen在监听端口,用accept来接受链接
TCP客户端需要conncet发送链接,
由于是流式数据,服务器和客户端读写使用read,write来读写,
TCP服务器首先需要创建一个listensocket并且与填写服务器地址的套接字地址进行绑定形成完整源端口,同时使用listensock来监听客户端发来的链接请求,accept来接受客户端发来的请求,由于listensock需要接受客户端的链接请求,为了保证不混乱,accpet会再创建一个socket用于与客户端进行通信,这个socket不需要bind了,因为它会自动继承listen套接字的bind信息,之后使用该socket,write,read即可。
TCP客户端首先需要创建一个socket,用于存放该主机接收与发送服务器的信息,填写一个要发送的服务器的套接字地址,connect会自动将本地的socket与本地的主机信息bind形成完整的源端口,再通过本地的socket,向套接字地址(目的地址)的服务器发起链接,链接成功后,通过该socket进行write,read即可
改善服务器
1. 解决文件描述符问题
当我们多次发起请求的时候,我们在服务器上的文件描述符越来越多了
文件描述符fd,是有限的,代表一个服务器同时能连接的客户机的个数,如果我们像我们这样就会导致一个问题那就是 fd泄漏问题!
所以,我们在服务结束的时候加上一句关闭对应的套接字就行了。
再次运行,这个问题就被解决了
但是,我们还有一个问题,那就是文件描述符的个数有没有上限呢?
答案是有的,我们知道文件描述符的本质就是数组的下标,那么既然是数组,数组就会有上限,那么怎么查看呢?
输入 ulimit -a
这个文件描述符个数,是指支持扩展的,一般服务器上的都会是65535甚至更多,个人pc就会好一点。
2. 解决单进程采用多进程
当我们打开两个客户端同时请求服务时,我们就会发现,只有前一个客户端可以获取服务。这是为什么呢?
其实就是我们的服务器是单进程的,但处理通信任务的时候,就不能再接受其他客户端发起的连接了。
我们改成多进程版的,才能进行通信处理多个链接。
像下面这样改可以吗?
当然不可以,主进程在等待子进程的时候,会阻塞住,这不还是只能处理一个服务吗?那怎么办?
这里有两种方法,第一种是采用捕捉信号的方式回收。
- 我们在系统编程中讲过,当子进程终止时,会向父进程发送SIGCHLD信号。我们可以在父进程中,捕捉这个信号并在信号处理函数中回收子进程的资源 。
- 在Linux下,可以直接忽略掉SIGCHLD信号,这样Linux内核会自动处理子进程的终止,直接释放子进程的资源,不再需要父进程显式地调用回收函数。
还有一种是,让孙子进程提供服务。子进程创建出孙子进程之后,马上退出,这样主进程就不会被阻塞住了,而孙子进程会变成孤儿进程,被系统领养,等进程结束,自动回收。
这里更推荐第一种,但是我们这是在学习,第一种方式我们在系统编程中就已经使用过了,所以这里采用第二种方式实现
编译运行,就能同时进行多个服务了。
这里我们发现服务器给两个连接分配的文件描述符都是4,这是为什么呢?
这就是父子进程使用不同文件描述符表的体现,实际执行任务的进程孙子进程,而父进程每次再将socket交给子进程,之后就之间关掉socket对应的fd了,所以我们这两个连接实际占据的是不同的两个进程的4号文件描述符,而服务器的4号进程每次分配成功,交给子进程之后,也就是启动服务之后,就为空了, 所以每次分配的时候才都是4
3. 解决成本问题,采用多线程
可创建一个进程的成本太高了,我们可以采用成本较小的线程。
但是这里监听套接字同样可以关吗?
当然不行,线程之间是共享文件进程符
线程通信,我们需要创建一个结构体来传递线程信息
但是创建线程,线程执行的函数必须得是void*(void*)的函数才行啊,我们的服务符合要求,那怎么办?
很简单,在我们的服务的基础上再封装一层,在计算机领域中,没有什么问题是封装一层没办法解决的,在之后的学习中,这种再封装一层的解决方案,我们会随处可见
但是需要注意的是,如果我们封装的这一层,在类内的话,是属于成员函数,虽然我们看着符合要求了,但是实际上就默认带上一个this指针,所以我们必须得封装为类函数,使用static修饰,把this指针去掉
编译运行,我们来验证下是否成功了
注意这里socketfd就是4,5了,这也就是体现了线程之间共享文件描述符表的特性
接下来,我们还可以使用线程池来提供服务, 但是由于线程池的特性,线程池更适合处理频繁短任务,或者是用户量少的情况。
像现在这种长连接如果用户很多的话,就需要建立多个线程,但这样线程池的轮转特性也就体现不出来了,所以这里我们只是学习采用一下。
真正到了业务上,采用的方案并不是我们现在这种,后面我们会介绍的
引入线程池,需要我们规定一个线程池执行的任务类型,比较常见的是定义为void(),后期无论我们的主任务函数如何改变,我们都可以通过bind函数,来改为void()
这两种构建任务的方式都是可以的。
编译运行,就可以实现通信了
简单的翻译
我们可以基于刚刚实现的线程池版,做一个简单的翻译服务器。
我们之前在UDP上已经实现过了,这里就可以平缓迁移过来了
首先,准备好一个文本
在实现一个字典类的头文件dict.hpp
接下来,我们要实现模块间的调用,首先我们在服务器中创建出服务器任务类型,并写入到服务器构造函数中,同样创建出服务器任务类型的成员变量
再将我们服务器主任务函数修改下
编译运行
写端问题
上面我们的代码看似没有问题了,但实际上还存在一些隐患
比如write函数,write函数也会哟出错的时候,我们也需要对write函数进行出错处理。
当我们向一个不存在的文件描述符中写入的时候,wirte函数就会出错,体现到我们服务器上就是某个socket可能因为某种原因关闭了,但是我们还想向那个文件描述符中写入了,这就会导致write函数出错,虽然并不是很致命的错误,但是我们也是需要预防下
同样的,我们对read函数也进行下包装
我们这里测试下,向第100个文件中写入数据,看下结果
write出错返回,符合预期。
还有一种情况,在使用管道通信的场景下,如果读端关闭了,写端再向管道里写。OS就会自动发送sigpipe信号把写端擦掉
我们不想因为这种异常终止服务器,所以可以将忽略此信号写在我们的服务器创建中。
connect断线重连
我们平时在玩游戏的时候,如果遇到网络不好,就会掉线,然后需要到掉线重连。那么掉线重连这个功能是怎么做得到呢?
下面,我们就来给我们的客户端增加一个掉线重连的功能。
首先,在定义两个变量,分别代表断线重连的次数,以及每次断线重连之间的时间间隔
同时定义一个最大重连次数,但重连次数大于这个值的时候,则代表重连失败。
再定义一个循环来实现重连逻辑,
注意: 退出退出循环的时候需要判断下,是正常连接成功了,还是连接超出重连次数连接失败了。
具体实现如下
但这样就完了吗?
当然不了,上面的断线重连的基本逻辑已经满足了,但是还会有一些隐患,比如当前实现的这一版本中,断线重连都是使用的同一个socketfd进行的,那如果这个socketfd本身就具有问题呢?
所以我们将其改善为第二个版本,断线重连都是基于不同的socketfd实现的。
实现起来很简单,就是将上面的socketfd申请写入到我们这个循环中即可。
我们的循环很巧妙的采用的是do... while结构,所以第一次申请socket的逻辑也能在这里完成。
不过不要忘了申请之间关掉旧的文件描述符
也就是这样
但上面这个版本依旧并不是很完美,虽然我们目前对于conncet本身断线重连的逻辑比较完善了,但是 如果连接建立好的基础上,断线呢?
这个时候,程序在下面执行,依旧不能够回到上面来执行断线重连的逻辑了,那怎么办?
很简单,加个循环,在外面再加一个大循环,当下面的IO通信出现错误的时候,就可以循环回到上面的断线重连。
不过需要注意的是:我们要区分好IO通信中退出时是连接出现了错误,还是我们自己单纯的想退出了,很简单我们在进入循环的时候写入一个标识符,通过标识符来判断。
编译运行,在客户端连上服务器后,断连,就会进行重连了。
这里我们实现的这是一个简易版本的,如果不满足于这个断线重连的话,我们后续会有一篇断线重连的文章,是基于状态机实现的,感兴趣的朋友可以来关注下。
bind 地址占用
我们在重启服务器的时候,发现有时服务器终止后,无法立刻重新启动,而是会报错
报错信息是bind函数的,信息是本地址已经被在使用中。
这个问题会在后面再进行讲解,这里我们只说解决方式。
解决这个问题,需要用到一个函数setsockopt.
参数说明
sockfd
:要操作的套接字描述符。level
:指定选项所在的协议层。常见的取值有SOL_SOCKET
(通用套接字选项)、IPPROTO_TCP
(TCP 选项)、IPPROTO_IP
(IP 选项)等。optname
:具体要设置的选项名称。例如,SO_RCVBUF
用于设置接收缓冲区大小,SO_SNDBUF
用于设置发送缓冲区大小,SO_REUSEADDR
用于允许地址重用等。optval
:指向一个缓冲区,该缓冲区包含要设置的选项值。optlen
:指定optval
缓冲区的大小。
编译运行,服务器就不会再发生这种情况了。