在我这篇博客:网络------socket编程中介绍了关于socket编程的一些必要的知识,以及介绍了使用套接字在udp协议下如何通信,这篇博客中,我将会介绍如何使用套接字以及tcp协议进行网络通信。
1. 前置准备
在进行编写代码之前,我们要明白一点:udp协议是面向数据报的,而tcp协议是面向字节流的,同时tcp是面向连接的,它要进行数据通信,就必须先连接,这些我们日后再解释。
udp协议和tcp协议所使用的socket文件是全双工的,也就是可以同时对该文件进行读写,而不会有任何影响,这一点在我上面的那篇博客中udp协议的套接字编程也有所体现。
那么我们现在就来开始tcp协议式的套接字编程。
2. 开始编码
a. 整体框架
整体框架还是一如既往:
Server.hpp:
客户端main函数:
服务端main函数:
b. Server.hpp
1). sockfd和bind
我们仍需要一个socket文件来被获取,以及需要bind成为网络进程:
2). listen
我们前面说过,要想使用tcp协议进行网络通信,就必须先让客户端与服务端连接,而这个连接申请一般是由客户端发起的,所以我们的服务端应该时刻 "监听" 客户端发过来的连接请求,所以我们要认识一个接口listen:
在这个接口中,我们第一个参数就是我们现在创建的所使用的_sockfd,第二个参数现在不做解释,我们给它填成5,这个函数也是成功返回0,失败返回-1,错误码被设置:
至此我们的tcp协议的初始化就完成了,当你使用tcp协议进行通信时,这段代码是始终不变的。
3). accept
接下来就是我们的Start函数了,上面说到,我们需要先建立连接,然后再进行通信,我们现在光知道客户端要连接服务端了(listen),我们还得接收它(accept):
这个函数的参数的第一个就是我们的_sockfd, 第二个和第三个参数就如同udp协议进行通信时的recvfrom中的那两个字段一样,作为输入输出型参数,主要是输出型参数来接收客户端的套接字信息。
这个函数我们需要重点关注它的返回值:
这句话的大致意思就是,当这个函数成功的话会给我们返回一个文件描述符。为什么这里要返回一个文件描述符呢?我们可以这样理解:
而其中,我们listen中的_sockfd就是那个招揽客人的人,这个accept返回的文件描述符就是服务员,而我们进行tcp通信时,客户端和服务端通信服务端使用的文件描述符就是这个accept返回的文件描述符:
所以我们应该,将成员变量_sockfd更名为_listenfd更合适一点。
4). echo server
现在我们开始编写如和接收客户端的信息,以及如何返回去,我们前面说过tcp是面向字节流的,所以我们的读写接口使用read和write接口也不令人意外了:
接下来我们的服务端就完成了。
接下来就是客户端的编写。
c. client
开始的套路不变:
这里我们再认识一个接口,那就是客户端如何向服务端发起连接请求:
内容不做解释了:
效果如下:
当有一端退出时,另一端目前也会退出:
还有一点,那就是当对这段代码测试时连续的运行server程序,会出现场这种情况:
这样的原因是因为程序退出后,并不会立即释放连接。我们只需要在Init函数中写这么一段代码就可以了:
这也是一种固定写法,原理是什么日后再说。至此我们就可以使用tcp协议进行简单的网络套接字通信了,由此可见tcp协议使用的文件描述符也是全双工的。
d. 引入多进程
上面的代码在面对一个客户时,没有什么问题,但是如果是多个用户要连接服务器呢?
我们发现只有上面的客户端可以正常进行通信,下面的客户端甚至都没有连接到服务器,当我们上面的客户端退出之后:
我们发现,下面客户端可以连接服务器了,并且一瞬间把消息返回来了。这其中的原因就是,我们的服务器是单进程的,在给第一个客户端提供服务时,我们的服务器进程就进入一个死循环了:
所以我们要将我们的服务器改为多进程的:
这样一来,当我们accept返回一个sockfd之后,我们就创建子进程,让子进程进行执行对该客户端的服务,然后然父进程回收子进程就可以了,但是这里还有个问题。当我们创建好子进程之后,子进程去执行任务去了,但是父进程需要等待子进程,等子进程结束之后父进程才能成功回收,然后继续接收切他客户端的连接,这样一来,这个代码不还是串行的,不能支持多个用户同时连接服务器嘛,所以我们还需要再改造一下:
在这里我们写了这样一段代码,当这段代码执行之后,子进程就又创建一个孙子进程,然后两者会继续执行后续的代码,但是此时我们让子进程直接退出,这个时候,父进程会直接回收子进程,而孙子进程由于它的父进程exit了,那么它就会成为孤儿进程被操作系统领养。这样我们就不需要让父进程一直等待了,而是直接对子进程回收之后就可以。
但是,还有问题。这个问题我使用图来表示一下:
在这个过程中,我们发现当我们的的孙子进程被创建好之后,执行完服务任务之后,关闭sockfd,退出之后,实际上这个sockfd并没有关闭,因为这个sockfd实际的指向还有一个父线程。这个文件结构体并不会被销毁,那么这样的话,当客户端连接的越来越多的话,我们父进程的文件描述符就会越来越多,这一点我们可以通过执行程序来看到:
但是要知道,一个进程的文件描述符数量是有限的,这样的话,就会出现文件描述符泄露的问题,所以我们需要,在各自的进程中,关闭不需要的文件描述符:
我们再来看程序运行:
从此以后我的们的父进程在接收返回来的sockfd之后就永远都是4了。也不会出现文件描述符泄露的问题。
上面的代码中我们的父进程还是需要等待子进程的并且我们接受一个客户端就需要创建两次子进程。这样效率有点低,所以我们再次改造:
我们直接对子进程退出时会给父进程发出的SIGCHLD信号进行忽略,这样的话,我们的子进程退出之后就不需要让父进程等待了,而是直接被操作系统释放:
我们发现,我们的代码在服务端被多个客户端连接后无法分辨客户端,所以我们再优化一下代码:
e. 多线程
我们知道创建进程就意味着,一整套内核资源要被重新创建,这样的效率还是较低下的,所以我们使用多线程,再次修改代码:
可以看到也可以进行多个客户端同时通信,并且也不需要关闭文件描述符了,因为文件描述符表也是多线程的共享资源。
但是,线程的创建也是有代价的,为什么不提前创建好一批线程呢?所以就有了线程池。
f. 线程池
在引入线程池之前,我要先更改代码中的一个地方:
在这个类中,我们将IP地址的网络字节序列转化为字符串,但是这个接口实际上不是那么安全:
我们可以看到,这个函数的返回值是一个char*类型的,这意味着,我们的返回的IP字符串,之前是被存在一个地方的,这个地方不是我们提供的,因为我们没有写,这个地方是C语言提供的,那就意味着,假如这个地方是固定的话,那就会导致多线程下的线程安全问题,所以我们需要换一个接口:
这么一改之后,这个函数的字符串的最终存储地址是我们单独在栈上提供的,就不用担心上面的问题了。
现在我们再来向代码中引入线程池:
这里我设置的线程池的数量是两个。
可以看到是可以正常通信的,但是:
当我再次连接一个客户端时,第三个客户端卡住了,这其实就是因为,我们的Service服务是一个死循环,我们线程池中的线程只有两个线程,而当第三个客户端连接过来之后,仅有的两个线程还在死循环中,所以就会导致第三个连接的客户端不能通信。
我们现在服务端提供的Service服务是一个死循环,是一个长任务,我们上面的线程池代码明显是不支持的,所以我们要改造代码,将Service长服务改为一个短服务,例如只完成一个功能(大小写转换,翻译,Ping服务器等):
首先服务器能提供一些服务:
注册服务通过服务端main函数中硬编码。
然后我们具体的服务应该是先让用户输入一个操作符,然后再接收一段用户想要被操作的内容,这段内容经过操作符对应的处理,将结果发送给用户:
接收用户操作符
接收用户想被操作的内容
根据操作符处理内容并返回结果
最后将结果发给用户
其中我们对于没有的服务以及ping功能是不需要用户再次输入内容的,所以我做了特殊处理。
在服务端mian函数中我们提供服务功能:
对于客户端我们也肯定要做出修改:
这其中本地文件内容如下:
而获取服务端服务列表是为了用户知道有哪些服务:
其中服务器中有一个翻译功能,我使用一个类对该功能进行封装:
这就是关于tcp和线程池实现的一个简单的短服务功能的代码。