上篇,我们讲解了udp服务器与客户端的功能,这篇我们将使用tcp协议来进行编程;tcp服务器相比较与udp要更加稳定与安全,tcp服务器是面向连接的数据传输;
1. tcp服务器与客户端
下面是我实现的完整代码可以辅助下面的讲解理解:
network_code/socket_2024_9_17/4_tcp · future/Linux - 码云 - 开源中国 (gitee.com)
下面我将对其值得注意的地方进行讲解;
1.1 socket套接字接口
socket与bind
由于这两个接口在上一篇就有讲过了,所以这里只是说明一下,证明他们的使用;
1.1.1 listen
这个函数是服务器用来监听socket套接字的接口,socket套接字通过上面的socket函数创建,socket函数创建好socket套接字之后,我们需要将这个绑定了本进程信息的套接字通过listen函数设置为listensock套接字,设置成功后这个listen函数会返回一个listensock,这个listensock负责监听底层有无其他进程向服务器进程进行访问,如果有进程通过网络来访问服务器进程,由于tcp协议是面向连接的,在底层中服务端会向客户端回信标识自己接收到了访问,这个listensock就是在底层进行回信的套接字,但是我们是不清楚他内部是如何实现的;
上面大致的讲解了listen接口做了什么下面我们来讲讲这个接口的使用;1.这个接口的返回值是一个套接字也可以看作是封装了sock的listensock网卡文件接口,之后服务器的工作就是通过listensock来实现的,当listen失败时会返回-1,且设置错误码errno;
2.函数的第一个参数是我们使用socket函数创建的本线程的socket套接字;
3.函数的第二个参数是一个回文,这个回文暂时不做讲解,现在只需要知道数值大小不需要设置太大,设置在5到10之间即可
1.1.2 connect
这个函数一般是客户端用来连接服务器的,因为服务器一般是被动接收很多不同客户端发送来的信息的,所以需要客户端先发送连接请求,而tcp协议在通信是是需要建立连接的(udp前面的操作就是直接通信,没有建立连接),而这个connect函数就是用来建立连接的函数,我们知道上面的listen函数设置了套接字来监听有什么客户端进程对服务器发起了访问;connnet函数就如同发起访问的函数一般,告诉服务器,我要来访问你了,你收到的话,请给我回信告诉我你收到了,如果客户端收到服务端接收成功的回信,connect就会返回0,说明建立连接成功,可以向服务端发送信息了,此时connect的底层其实也会发送信息给服务端,告诉服务端我收到了你的回信,但底层的实现我们也还是不清楚的;
接下来我们继续介绍函数的使用;1.connect的返回值建立连接成功返回0失败返回-1并设置errno错误码
2.connect的第一个参数是客户端所创建的套接字
3.connect的第二个和第三个参数是要访问的服务器的ip地址与端口号
1.1.3 accept
上面的两个函数都是服务器用来接收客户端回信的函数,accept4功能比accept多一个flag的设置功能,我们暂时先不管他即可;上面说到connect函数向服务器回信告诉服务器连接已经建立成功我们可以开始通信了,此时由于listensock是用来监听访问的,无法再为客户端的请求服务,所以需要通过accept函数来接收客户端回信,还要分配新的socket来对客户端的请求服务;accept的底层会创建出新的套接字作为返回值,提供给程序员;
1.accept的返回值是一个sockfd这个sockfd是用来为客户端提供服务的sock,我们可以从这个网卡文件描述符中获取客户端发来的信息,也可以向其中返回我们的数据,和普通文件描述符一般使用即可,返回值为-1表示接收出现错误,并设置errno;2.accept的第1个参数是监听套接字,用来获取监听到的来访问的客户端
3.accept的第2和第3个参数是输出型参数可以获取访问的客户端的信息的sockaddr结构体与结构体大小;
1.2 telnet连接命令
通过上面的接口介绍,我们再联系我代码的实现就可以基本的写出tcp客户端和服务器的大致功能,接下来呢介绍一个linux下现成的工具telnet,使用方法:
telnet (ip地址) (端口号)
例:telnet 127.0.0.1 10000
这个小工具可以完成对服务器发送信息,并接收服务器返回数据的功能;
1.3 发送信息转换网络字节序转换
我们再bind和获取sockaddr信息时都需要将数据进行网络字节序的转换,那为什么我们发送到网络中的信息不需要进行网络字节序的转换呢?其实我们不需要纠结这些,既然我们能够做到信息的成功传输那么就说明,再socket套接字的底层一定是对信息做了处理,使得信息在网络中的传输是符合网络字节序的;而例如sin_addr和sin_port这样的信息是我们再用户层写的,要传入内核中的数据,这样的特例需要我们进行显式的修改而已;
2.tcp服务器服务实现方式
我们对tcp服务器有几种不同的实现方式:
cpp
1.单进程,无法做到同时为多个客户端服务
serverce(socketfd, userIp, userPort);
close(socketfd);
2.多进程
pid_t pid = fork();
if (pid == 0)
{
// 子进程
close(_socketListen);
if (fork() != 0)
exit(-1);
serverce(socketfd, userIp, userPort);
close(socketfd);
exit(-1); // 孙子进程也要记得退出哦
}
父进程
close(socketfd);
waitpid(pid, nullptr, 0);
3.多进程 使用signal函数进行等待,父进程不需要阻塞等待
pid_t pid = fork();
if (pid == 0)
{
// 子进程
close(_socketListen);
serverce(socketfd, userIp, userPort);
close(socketfd);
exit(-1);//子进程要记得退出哦
}
// 父进程
close(socketfd);
4.进程池,在run函数最开始就创建多个进程,进程竞争的接收
客户端,但是需要注意对与socket的accept需要加锁访问
5.多线程
pthread_t tid;
threadData* data= new threadData(socketfd, userIp, userPort, this);
pthread_create(&tid, 0, routine, (void *)data);
6.线程池
task t = task(socketfd, userIp, userPort);
threadPool<task>::getThreadPool()->push(t);
这些实现方式是层层递进的,我们一开始使用的是单进程方式,但是由于tcp是面向连接的连接的用户,一个进程只能服务一个客户端所以,这样的服务器是不合格的;------>转变为了多进程的服务,但是虽然多进程服务,进程创建的消耗太大了;------>转变为了多线程服务,多线程消耗小,但是线程的创建也是有代价的;------>最后将服务设计成多线程的服务,并且限制线程数量,用线程池来跑,再把服务修改为短服务,这样使得服务器可以长久的运行;如何实现可以通过上面的代码来了解,这里就不多做讲解了;
3.tcp客户端的断连重连功能
我们将服务器的功能基本完善后,由于将服务器设置为了短服务,所以我们的客户端想要连接服务器需要不断重新accept去连接服务器,那么这样的过程我们还可以丰富一下再加上重复连接的功能,使得客户端的模型更加贴近我们现实生活中的场景:
会让服务器的功能大致成为上面这样;
我们可以看看我们服务器的重连现象:
我们只需要看到,我们是可以实现重连功能的,至于我上面实现的是什么样的服务可以先不用了解,上面实现的服务一个翻译功能,我们输入英文,其可以翻译中文给我们;在我们打王者荣耀的时候,其实我们掉线了其实也是有这个重连功能的:
其实重连功能基本都是这样哒!
3.1 setcocketopt接口
我们实现的服务器在重新启动时有可能会存在端口号被占用无法马上重新启动服务器的情况这个时候,我们只需要使用这个接口设置一下即可解决此问题:
cpp
// 下面的代码用来让服务器重启不需要等待时间,暂时不知道原理,先用着
int opt = 1;
setsockopt(_socketListen, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
4.tcp服务器翻译功能的实现
下面是代码实现,其实在我上面的gitee链接中也有,这里拿出来更方便观看
cpp
#pragma once
#include <iostream>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/stat.h>
#include "log.hpp"
#include <cstring>
#include <unistd.h>
#include <unordered_map>
#include <fstream>
class dict
{
public:
void Init()
{
ifstream in("./dict.txt");
string str;
while (getline(in, str))
{
string key, value;
auto pos = str.find(':');
if (pos == string::npos)
continue;
key = str.substr(0, pos);
value = str.substr(pos + 1);
_dict.insert({key, value});
}
}
static dict *getdict()
{
// 这里因为是只读,不会对其修改,所以不需要上锁
if (_self == nullptr)
{
_self = new dict;
}
return _self;
}
unordered_map<string, string> _dict;
private:
dict() {}
dict(const dict &) = delete;
dict &operator=(const dict &) = delete;
static dict *_self;
};
dict* dict::_self = nullptr;
dict.txt文件:
yellow:黄色
red:红色
pig:猪
cat:猫
dog:狗
通过将一个字典载入服务器中,让服务器可以通过这个字典的键值对获取相应的翻译:
cpp
for(auto word:dict::getdict()->_dict)
{
if(word.first==getMsg)
sendInfo+=word.second;
}
if(sendInfo.size()==0)
sendInfo="unkonw";
5.守护进程化
在我们现实生活中,我们使用的app如王者荣耀,抖音,这些app无论我们什么时候,我们只要联网了就可以使用这些软件,所以这些软件提供的服务都是24小时的,那么他们的服务器也一定是24小时运行的;而如果想要一个进程每天24小时运行,那么这个进程一定不能被误杀并且不能出现让进程死亡的bug,那么我们怎么样才能做到呢?接下来我们就通过守护进程化来做到;首先我们先讲解一下,前后台任务的概念:
5.1 前台后台任务
5.1.1 什么是会话与前后台任务与bash和shell
首先我们需要介绍一个名词------会话,在我们使用电脑时,电脑也可以创建多个用户,每个不同的用户登录上电脑就会生成不同的会话。而我们云服务器在登录远程主机时每次登录都会创建一个会话,我们可以这样理解:
对于我们的会话,我们可以这么理解,会话是一个用户在使用主机时所产生的与主机交互的一个实体,我们运行的进程还有需要的数据都会显示在会话上,而大多数时刻一个会话在一个时刻是只能显示一个页面的,那么在这个页面上显示的就是我们的前台进程(任务),所以一个会话只能在一个时刻存在一个前台进程(任务)
而在我们的linux云服务器上我们可以通过xshell对其进行远程连接,我们每建立一次连接,就是在远程的云服务器主机上建立了一个会话session,而这些每个不同的会话都会有他们自己的唯一的前台程序(任务),而bash就是linux下的一个默认的前台程序(任务),他会将我们标准输入(键盘)上的数据进行解析,运行出我们想要执行的指令;Bash(全称为 "Bourne Again SHell")是一个命令行解释器;
shell是用来与操作系统进行交互的接口,他有bash还有图形化界面多种表示方式
我们下面使用grep查看也可以查看到bash默认前台任务:
那么什么样的进程(任务)会被称前台程序(任务)呢?
我们可以将拥有标准输入流的进程当作前台程序,在windows这样带有图形化界面的程序上不仅仅是键盘还有鼠标拥有这些输入的就是前台程序(任务);
5.1.2 前后台任务切换指令
我们理解了什么是前后台任务后,在linux下我们可以使用一些命令让我们的任务进行前后台的切换:
1.将前台任务置换为后台任务指令:
./进程名 & 例如: ./a.out &
我们有一个这样的程序:
当我们使用&运行它时:
这个程序这样运行我们无法使用ctrl+将其终止,因为我们ctrl+c会发送信号给前台进程,而这里的test是后台进程了,所以也证明了只有前台程序才拥有标准输入流(键盘);
2.查看后台程进程令命令
jobs
3.将后台进程提到前台命令
fg 任务号 例如: fg 1
4.将后台暂停程序重新启动
我们可以使用ctrl+z让前台运行的程序暂停,bash会自动被置换回前台
bg 任务号
5.1.3 任务与进程组
有事一个任务会分配给一个进程组,而一个进程组中可能有多个进程,例如fork创建的进程,这些进程在一个组共同处理任务,所以我们也叫这些任务叫前后台任务,例如我们windows下的编译器,就是一个个进程组来共同处理任务:
看上面的vs code进程组的例子;
任务是指派给进程组的,只不过有些进程组只有一个进程罢了
5.2 实现守护进程化
上面我们铺垫了这么久,我们究竟如何做到守护进程化呢?
我们需要分这几步:
cpp
void daemon()
{
if(fork()!=0)exit(-1);//父进程直接退出
//忽略信号,让信号不会误杀进程
signal(SIGPIPE,SIG_IGN);
signal(SIGCLD,SIG_IGN);
signal(SIGSTOP,SIG_IGN);
setsid();//自成会话
//更改pwd
//chdir("/");
//重定向012fd到/dev/null 堵不如疏
int fd=open("/dev/null",O_RDWR);
dup2(fd,0);
dup2(fd,1);
dup2(fd,2);
}
1.srtsid(),这个函数是用来让进程自成会话的,使得一个终端退出时,让我们的进程不会被终端的会话影响而退出,让服务器成为独立的会话中的进程,也叫自成会话;
2.让父进程退出创建子进程的原因是,我们进程组的组长不允许自成会话,所以创建出子进程,让子进程执行代码,父进程组长退出,子进程成为孤儿进程让系统领养即可
3.我们还可以修改pwd,进程的运行目录,防止被同一目录下的其他进程影响
4.最后关闭所有输出,让输出全部输出到/dev/null文件下,这个文件是专门用来提供给程序员输出不需要信息的,需要的log日志信息会通过log代码输入到我们需要的文件中;
通过这些操作,就可以让我们的进程稳定在后台运行;
其实库中提供给了我们守护线程的函数,只不过一般这个守护进程化都是我们程序员自己实现的:
第一个参数为是否修改pwd路径到"/"根目录,第二个参数为是否重定向0,1,2fd文件描述符到/dev/null文件中;
6.tcp协议过程
我们前面说了这么多函数,接下来我们来总结一下tcp协议的通信过程:
就是通过上面三次握手的过程建立了连接,其实这三次握手实际的操作,我们是看不到的都是底层的套接字在做,我们只需要调用接口就好了;四次挥手也是同理有底层套接字完成;
因为是面向连接的通信,所以服务器端会有很多个套接字和客户端连接,套接字也是通过先描述后组织的方式被管理起来的,就是说服务客户端的套接字会在服务端形成一个数据结构被管理起来;
以上便是tcp协议通信的过程;