> 目标:TCP网络服务器简单模拟实现。
> 毒鸡汤:有些事情,总是不明白,所以我不会坚持。早安!
> 专栏选自:网络
> 望小伙伴们点赞👍收藏✨加关注哟💕💕
前面我们已经学习了网络的基础知识,对网络的基本框架已有认识,算是初步认识到网络了,如果上期我们的学习网络是步入基础知识,那么这次学习的板块就是基础知识的实践,我们今天的板块是学习网络重要之一,学习完这个板块对虚幻的网络就不再迷茫!!!

学习【网络】套接字编程——TCP通信咱们按照下面的图解:

分别实现客户端与服务器,客户端向服务器发送消息,服务器收到消息后,回响给客户端,有点类似于 指令:
这个程序我们已经基于 协议实现过了,换成 协议实现时,程序的结构是没有变化的,同样需要 、、、 这几个文件:
创建 服务器头文件:
创建 服务器源文件:
创建 客户端头文件:
创建 客户端源文件:
创建 文件:
2.2.1 socket、bind - 初始化服务端
说明:
在使用 socket 函数创建套接字时,UDP 协议需要指定参数2为 SOCK_DGRAM,TCP 协议则是指定参数2为 SOCK_STREAM。
代码呈现:
server.hpp的初始化部分:
解释说明:
- 在绑定端口号时,一定需要把主机序列转换为网络序列。
- 发送信息阶段,recvfrom / sendto 等函数会自动将需要发送的信息转换为网络序列,接收信息时同样会将其转换为主机序列,所以不需要手动转换。
总结:
TCP是面向连接的,服务器一般是比较被动的,没人访问,这个服务器只能干等着,而且也不能退出。就像你是一家餐馆的老板,你只能在餐馆里被动的等待顾客的到来,顾客什么时候来你也不知道。服务器要一直要处于等待链接到来的状态——监听状态。
2.2.2 listen - 监听一个套接字
语法:
使用说明:
listen() 函数的主要作用就是将套接字( sockfd )变成被动的连接监听套接字(被动等待客户端的连接)。所谓被动监听,是指当没有客户端请求时,套接字处于“睡眠”状态,只有当接收到客户端请求时,套接字才会被“唤醒”来响应请求。
参数说明:
- int sockfd:服务端的socket,也就是socket函数创建的,标识绑定的,未连接的套接字的描述符。
- int backlog:backlog 为请求队列的最大长度。
细节说明:
listen() 只是让套接字处于监听状态,并没有接收请求。接收请求需要使用 accept() 函数。
代码呈现:
server.hpp的初始化服务器部分:
2.2.3 accept - 获取一个新连接
语法:
使用说明:
- accept() 会阻塞程序执行(后面代码不能被执行),直到有新的请求到来。
- accept() 返回一个新的套接字来和客户端通信,addr 保存了客户端的IP地址和端口号,而 sockfd 是服务器端的套接字,大家注意区分。后面和客户端通信时,要使用这个新生成的套接字,而不是原来服务器端的套接字。
参数说明:
- sockfd:为服务器端套接字。
- addr:sockaddr_in 结构体变量。
- addrlen:参数 addr 的长度,可由 sizeof() 求得。
- 与 :是一个 输入输出型 参数,类似于 中的参数。
基于TCP连接的服务器端为什么需要用两个套接字:
- 在服务器端,socket()返回的套接字用于监听(listen)和接受(accept)客户端的连接请求。这个套接字不能用于与客户端之间发送和接收数据。
- 当某个客户端断开连接、或者是与某个客户端的通信完成之后,服务器端需要关闭用于与该客户端通信的套接字。
代码呈现:
server.hpp的StartServer的内容:
测试工具-->telnet:
安装:
功能说明:
- telnet是一种用于远程登录的网络协议,可以将本地计算机链接到远程主机。
- Linux提供了telnet命令,它允许用户在本地计算机上通过telnet协议连接到远程主机,并执行各种操作。
- 使用telnet命令可以建立客户端与服务器之间的虚拟终端连接,这使得用户可以通过网络远程登录到其他计算机,并在远程计算机上执行命令,就像直接在本地计算机上操作一样。
使用说明:
参数说明:
- -l 用户名:指定用户名进行登录。
- -p 端口号:指定要连接的远程主机端口号。
- -E:在telnet会话中打开字符转义模式。
- -e 字符:指定telnet会话中的转义字符。
- -r:在执行用户登录之前不要求用户名。
- -K:在连接到telnet会话时要求密码。
2.2.4 read - 从套接字中读取数据
功能说明:
- 因为 是面向字节流的,所以可以直接使用 系统调用去读取数据。
- 如果客户端退出了,那么 会读到0,此时需要把之前 返回的 关闭,防止误操作,造成意想不到的结果。
2.2.5 write - 向套接字中进行写入
使用:
同理,向套接字中进行写入时,直接使用 write 系统调用即可。服务端在收到客户端的数据后,先进行加工处理,然后再进行写入,上面 if(n > 0) 后面就是写入的代码。唯一需要注意的就是,如果在写入前或者正在写入的过程中,client 端退出了,此时客户端与服务器之间的连接就断了,此时客户端如果进行写入操作可能会导致整个服务端崩掉。这和管道类似,读端关闭,写端继续写,操作系统会给写端发送 13 号信号,将写端 kill 调,为了避免这种情况,我们需要在服务器启动的时候将 13 号新号进行捕捉。
2.2.6 总代码呈现
server.hpp:
server.cc代码:
功能说明:
client.cc代码:
2.3.1 socket - 初始化客户端
说明:
- 对于客户端来说,服务器的 IP 地址与端口号是两个不可或缺的元素,因此在客户端类中,server_ip 和 server_port 这两个成员是少不了的,当然得有 socket 套接字。
- 初始化客户端只需要干一件事:创建套接字,客户端是主动发起连接请求的一方,也就意味着它不需要使用 listen 函数设置为监听状态。
- TCP版本是不需要我们手动写代码去bind的,也是操作系统自己去自动bind的。
代码呈现:
客户端头文件:
2.3.2 connect - 向服务端发起连接
语法:
参数说明:
- int sockdf:socket文件描述符
- const struct sockaddr *addr:传入参数,指定服务器端地址信息,含IP地址和端口号
- socklen_t addrlen:传入参数,传入sizeof(addr)大小
- 返回值:成功为 0,失败为 -1。
代码呈现:
2.3.3 write、read - 向服务器发送数据、从服务器接收数据
和服务端一样,客户端也是通过 write 和 read 接口来发送数据和读取数据。
2.3.4 代码呈现
client.hpp代码:
client.cc代码:
2.4.1 父进程阻塞等待
功能阐述:
当服务器成功处理连接请求后,fork 新建一个子进程,用于进行业务处理,原来的进程专注于处理连接请求,子进程创建成功后,会继承父进程的文件描述符表,能轻而易举的获取客户端的 socket 套接字,从而进行网络通信,当然不止文件描述符表,得益于 写时拷贝 机制,子进程还会共享父进程的变量,当发生修改行为时,才会自己创建。
代码呈现:
细节说明:
虽然此时成功创建了子进程,但父进程(处理连接请求)仍然需要等待子进程退出后,才能继续运行,说白了就是 父进程现在处于阻塞等待状态,因此父进程应该需要设置为 非阻塞等待。
2.4.2 非阻塞等待版本
想让父进程不阻塞等待,方法如下:
- 通过参数设置为非阻塞等待(不推荐)
- 设置 信号的处理动作为子进程回收(不是很推荐)
- 忽略 信号(推荐使用)
- 设置孙子进程(不是很推荐)
2.4.3 代码呈现
从内核的观点看:
- 进程的目的就是担当分配系统资源(CPU时间、内存等)的基本单位。
- 线程是进程的一个执行流,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
2.5.1 使用原生线程库
概念:
由于我们创建线程是用来提供服务的,而服务端的业务中有一个Service(),它需要我们的线程去传入 Service() 函数中的所有参数,同时也需要具备调用 Service() 业务处理函数的能力,我们只能把Service() 函数中的所有参数和this指针传进去,而这单凭一个 void* 的参数是无法解决的,为此我们可以创建一个类,里面可以包含我们所需要的参数——Service() 函数中的所有参数和this指针。
分析:
所以接下来我们需要在连接成功后,创建次线程,利用已有信息构建 ThreadData 对象,为次线程编写回调函数(最终目的是为了执行 Service() 函数)。
代码呈现:
server.hpp代码:
makefile代码呈现:
2.5.2 线程池版本
问题分析:
如果每来一个用户我们就得创建一个线程,那么当来了很多用户,就会消耗很多资源。我们不想等到客户来了才创建我们的线程,我们可以提前创建好,我们不提供死循环服务,为此可以改用之前实现的线程池。
代码呈现:
ThreadPool.hpp代码:
Task.hpp代码:
server.hpp代码:
server.cc代码呈现:
2.6.1 守护进程( Daemon)
概念:
- 守护进程是一种长期运行的进程(守护进程的生存期不一定长,但一般应该这样做),一般是操作系统启动的时候它就启动,操作系统关闭的时候它才关闭。
- 守护进程跟终端无关联,也就是说它们没有控制终端,所以控制终端退出,也不会导致守护进程退出。
- 守护进程是在后台运行的,不会占着终端,终端可以执行其他命令。
分析:
- 守护进程是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或循环等待处理某些事件的发生;它不需要用户输入就能运行而且提供某种服务,不是对整个系统就是对某个用户程序提供服务。Linux系统的大多数服务器就是通过守护进程实现的。
- 守护进程一般在系统启动时开始运行,除非强行终止,否则直到系统关机才随之一起停止运行。
- 守护进程一般都以root用户权限运行,因为要使用某些特殊的端口(1-1024)或者资源。
- 守护进程的父进程一般都是init进程,因为它真正的父进程在fork出守护进程后就直接退出了,所以守护进程都是孤儿进程,由init接管。
- 守护进程是非交互式程序,没有控制终端,所以任何输出,无论是向标准输出设备stdout还是标准出错设备stderr的输出都需要特殊处理。
- 守护进程的名称通常以d结尾,比如sshd、xinetd、crond等。
细节说明:
- 守护进程是一个生存周期较长的进程,通常独立于控制终端并且周期性的执行某种任务或者等待处理某些待发生的事件。
- 大多数服务都是通过守护进程实现的。
- 关闭终端,相应的进程都会被关闭,而守护进程却能够突破这种限制。
总结:
- 守护进程不会收到来自内核的 SIGHUP 信号,也就是说,如果守护进程收到了 SIGHUP 信号,那么肯定是另外的进程发的。
- 守护进程把 SIGHUP 信号作为通知信号,表示配置文件已经发生改动,守护进程应该重新读入其配置文件。
- 守护进程不会收到来自内核的 SIGINT 信号(Ctrl+C)、SIGWINCH 信号(终端窗口大小改变)。
2.6.2 进程组
什么是进程组:
进程组就是一个或多个进程的集合。这些进程并不是孤立的,他们彼此之间或者存在父子、兄弟关系,或者在功能上有相近的联系。每个进程都有父进程,而所有的进程以init进程为根,形成一个树状结构。
Linux为什么要有进程组:
提供进程组就是为了方便对进程进行管理。假设要完成一个任务,需要同时并发100个进程。当用户处于某种原因要终止 这个任务时,要是没有进程组,就需要手动的一个个去杀死这100个进程,并且必须要严格按照进程间父子兄弟关系顺序,否则会扰乱进程树。
修改进程组ID的接口:
这个函数的含义是,找到进程ID为pid的进程,将这个进程的进程组ID修改为pgid,如果pid的值为0,则表示要修改调用进程的进程组ID。该接口一般用来创建一个新的进程组。
2.6.3 作业
概念:
Shell分前后台来控制的不是进程而是作业(Job)或者进程组(Process Group)。一个前台作业可以由多个进程组成,一个后台也可以由多个进程组成,Shell可以运行一个前台作业和任意多个后台作业,这称为作业控制。
作业与进程组的区别:
- 如果作业中的某个进程又创建了子进程,则子进程不属于作业。一旦作业运行结束,Shell就把自己提到前台,如果原来的前台进程还存在(如果这个子进程还没终止),它自动变为后台进程组。
- 一个或多个进程组的集合,比如用户从登陆到退出,这个期间用户运行的所有进程都属于该会话周期。
2.6.4 会话
概念:
由于Linux是多用户多任务的分时系统,所以必须要支持多个用户同时使用一个操作系统。当一个用户登录一次系统就形成一次会话 。一个会话可包含多个进程组,但只能有一个前台进程组。每个会话都有一个会话首领(leader),即创建会话的进程。 sys_setsid()调用能创建一个会话。
语法:
如果这个函数的调用进程不是进程组组长,那么调用该函数会发生以下事情:
- 创建一个新会话,会话ID等于进程ID,调用进程成为会话的首进程。
- 创建一个进程组,进程组ID等于进程ID,调用进程成为进程组的组长。
- 该进程没有控制终端,如果调用setsid前,该进程有控制终端,这种联系就会断掉。
2.6.5 控制终端
概念:
与控制终端建立连接的会话领头进程称为控制进程 (session leader) ,一个会话可以有一个控制终端。这通常是登陆到其上的终端设备(在终端登陆情况下)或伪终端设备(在网络登陆情况下)。建立与控制终端连接的会话首进程被称为控制进程。
总结:
- 进程属于一个进程组,进程组属于一个会话,会话可能有也可能没有控制终端。一般而言,当用户在某个终端上登录时,一个新的会话就开始了。进程组由组中的领头进程标识,领头进程的进程标识符就是进程组的组标识符。类似地,每个会话也对应有一个领头进程。
- 同一会话中的进程通过该会话的领头进程和一个终端相连,该终端作为这个会话的控制终端。一个会话只能有一个控制终端,而一个控制终端只能控制一个会话。用户通过控制终端,可以向该控制终端所控制的会话中的进程发送键盘信号。
- 同一会话中只能有一个前台进程组,属于前台进程组的进程可从控制终端获得输入,而其他进程均是后台进程,可能分属于不同的后台进程组。
2.6.6 创建守护进程的过程
①fork()创建子进程,父进程exit()退出:
这是创建守护进程的第一步。由于守护进程是脱离控制终端的,因此,完成第一步后就会在Shell终端里造成程序已经运行完毕的假象。之后的所有工作都在子进程中完成,而用户在Shell终端里则可以执行其他命令,从而在形式上做到了与控制终端的脱离,在后台工作。
②在子进程中调用 setsid() 函数创建新的会话:
在调用了fork()函数后,子进程全盘拷贝了父进程的会话期、进程组、控制终端等,虽然父进程退出了,但会话期、进程组、控制终端等并没有改变,因此,这还不是真正意义上的独立开来,而 setsid() 函数能够使进程完全独立出来。
③再次 fork() 一个孙进程并让子进程退出:
为什么要再次fork呢,假定有这样一种情况,之前的父进程fork出子进程以后还有别的事情要做,在做事情的过程中因为某种原因阻塞了,而此时的子进程因为某些非正常原因要退出的话,就会形成僵尸进程,所以由子进程fork出一个孙进程以后立即退出,孙进程作为守护进程会被init接管,此时无论父进程想做什么都随它了。
④在孙进程中调用 chdir() 函数,让根目录 ”/” 成为孙进程的工作目录:
这一步也是必要的步骤,使用fork创建的子进程继承了父进程的当前工作目录。由于在进程运行中,当前目录所在的文件系统(如“/mnt/usb”)是不能卸载的,这对以后的使用会造成诸多的麻烦(比如系统由于某种原因要进入单用户模式)。因此,通常的做法是让"/"作为守护进程的当前工作目录,这样就可以避免上述的问题,当然,如有特殊需要,也可以把当前工作目录换成其他的路径,如/tmp,改变工作目录的常见函数是chdir。
⑤在孙进程中调用 umask() 函数,设置进程的文件权限掩码为0:
文件权限掩码是指屏蔽掉文件权限中的对应位。比如,有个文件权限掩码是050,它就屏蔽了文件组拥有者的可读与可执行权限。由于使用fork函数新建的子进程继承了父进程的文件权限掩码,这就给该子进程使用文件带来了诸多的麻烦。因此,把文件权限掩码设置为0,可以大大增强该守护进程的灵活性。设置文件权限掩码的函数是umask。在这里,通常的使用方法为umask(0)。
⑥在孙进程中关闭任何不需要的文件描述符:
- 同文件权限码一样,用fork函数新建的子进程会从父进程那里继承一些已经打开了的文件。这些被打开的文件可能永远不会被守护进程读写,但它们一样消耗系统资源,而且可能导致所在的文件系统无法卸下。
- 在上面的第2)步之后,守护进程已经与所属的控制终端失去了联系。因此从终端输入的字符不可能达到守护进程,守护进程中用常规方法(如printf)输出的字符也不可能在终端上显示出来。所以,文件描述符为0、1和2 的3个文件(常说的输入、输出和报错)已经失去了存在的价值,也应被关闭。
⑦守护进程退出处理:
当用户需要外部停止守护进程运行时,往往会使用 kill 命令停止该守护进程。所以,守护进程中需要编码来实现 kill 发出的signal信号处理,达到进程的正常退出。
2.6.7 直接调用系统现成的接口
语法:
参数说明:
- nochdir:如果该参数为0,则将当前工作目录更改为根目录;如果为1,则不更改当前工作目录。
- noclose:如果该参数为0,则关闭所有与终端相关的文件描述符;如果为1,则不关闭文件描述符。
服务器源文件:
2.6.8 自己创建守护进程版本的服务器
手动实现守护进程时需要注意以下几点:
- 忽略异常信号
- 0、1、2 要做特殊处理(文件描述符)
- 进程的工作路径可能要改变(从用户目录中脱离至根目录)
具体实现步骤如下:
- 忽略常见的异常信号:SIGPIPE、SIGCHLD。
- 如何保证自己不是组长? 创建子进程 ,成功后父进程退出,子进程变成守护进程。
- 新建会话,自己成为会话的 话首进程。
- (可选)更改守护进程的工作路径:chdir。
- 处理后续对于 0、1、2 的问题。
对于标准输入、标准输出、标准错误 的处理方式有两种:
- 暴力处理:直接关闭 fd
- 优雅处理:将 fd 重定向至 /dev/null,也就是 daemon() 函数的做法
2.6.9 代码呈现
守护进程头文件:
Task.hpp代码:
ThreadPool.hpp代码:
server.hpp代码:
server.cc代码:
2.7.1实际连接过程没有这么简单
解释:
真正连接的过程实际就是双方操作系统三次握手的过程,这个过程是由双方的操作系统自动完成的。 我们知道上层发起连接请求和收获连接结果是通过connect和accept系统调用来完成的,而真实的连接过程和这两个系统调用没什么关系,连接过程是由双方的操作系统执行各自的内核代码自动完成连接过程的。
所以accept并不参与三次握手的任何细节,他仅仅只负责拿走连接结果的胜利果实。换句话说,就算上层不调用accept,三次握手的过程也能够建立好,因为应用是应用,底层是底层,三次握手就是底层,和你应用没半毛钱关系,这是我双方的操作系统自主完成的工作。
另外我们所说的TCP协议保证可靠性和应用有关系吗?照样没半毛钱关系!因为应用是应用,底层是底层,TCP协议是传输层的,传输层在操作系统内部实现。
2.7.2 维护TCP的连接有成本嘛
答案有:
一定是有的,因为双方的操作系统要在各自底层建立描述连接的结构对象,然后用数据结构将这些结构对象管理起来,这些都是要花时间和内存空间的,所以维护连接一定是有成本的。
2.7.3 简单理解三次握手和四次挥手
理解三次握手和四次挥手:
- 在链接过程中,tcp采用三次握手。
- 在断线过程中,tcp采用四次挥手。
三次握手:
client调用connect,向服务器发起连接请求,connect会发出SYN段并阻塞等待服务器应答(第一次),服务器收到客户端的SYN段后,会给客户端应答一个SYN-ACK段表示"同意建立连接"(第二次),客户端收到SYN-ACK段后会从connect系统调用返回,同时应答一个ACK段(第三次),此时连接建立成功。
四次握手:
客户端如果没有请求之后,就会调用close关闭连接,此时客户端会向服务器发送FIN段(第一次),服务器收到FIN段后,会回应一个ACK段(第二次),同时服务器的read会读到0,当read返回后服务器就会知道客户端关闭了连接,他此时也会调用close关闭连接,同时向客户端发送FIN段(第三次), 客户端收到FIN段后,会给服务器返回一个ACK段(第四次)。 (socketAPI的connect被调用时会发出SYN段,read返回时表明服务器收到FIN段)
2.7.4 TCP通信的实质
TCP通信的实质:
- 这些全部都是由TCP协议自己决定的,这是操作系统内部的事情,和我们用户层没有任何瓜葛,这也就是为什么TCP叫做传输控制协议的原因,因为传输的过程是由他自己所控制决定的。
- c->s和s->c之间发送使用的是不同对的发送和接收缓冲区,所以c给s发是不影响s给c发送的,这也就能说明TCP是全双工的,一个在发送时,不影响另一个也再发送,所以网络发送的本质就是数据拷贝。

应用层缓冲区是什么:
其实所谓的应用层缓冲区就是我们自己定义的buffer,可以看到下面的6个网络发送接收接口都有对应的buf形参,我们在使用的时候肯定要传参数进去,而传的参数就是我们在应用层所定义出来的缓冲区。
Socket.hpp代码:
server.hpp代码:
今天内容就到这里啦,时间过得很快,大家沉下心来好好学习,会有一定的收获的,大家多多坚持,嘻嘻,成功路上注定孤独,因为坚持的人不多。那请大家举起自己的小手给博主一键三连,有你们的支持是我最大的动力💞💞💞,回见。
版权声明:
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若内容造成侵权、违法违规、事实不符,请将相关资料发送至xkadmin@xkablog.com进行投诉反馈,一经查实,立即处理!
转载请注明出处,原文链接:https://www.xkablog.com/hd-yjs/17202.html


