当前位置: 首页 > news >正文

Linux-> TCP 编程1

目录

本文说明:

一:TCP VS UDP

二:TCP编程接口

1:listen

2:accept

3:recv和read

4:send和read

5:connect

三:echo程序

1:单进程版本

①:TcpServer.hpp

②:Main.cc

③:MainClient.cc

④:Log.hpp

⑤:InetAddr.hpp

⑥:makefile

⑦:运行效果

2:多进程版本

①:效果

②:改动代码

3:多线程版本

①:效果

②:改动代码

4:线程池版本

①:改动代码

5:服务器类的总代码


本文说明:

在前面我们已经了解了UDP编程,其实TCP编程代码和UDP很相似,所以相似的地方不再赘述,UDP的博客:https://blog.csdn.net/shylyly_/article/details/151292001

而本篇博客会介绍TCP与UDP之间的差异,最后用TCP编程实现一个echo程序,echo程序有四个版本,①:单进程(单线程)版本 ②:多进程版本 ③:多线程版本 ④:线程池版本,从易到难~

一:TCP VS UDP

TCP和UDP最大的不同,就是编写代码时候的逻辑的不同,我们的UDP无论是客户端还是服务端逻辑都大致如下:

①:创建套接字

②:bind绑定(客户端隐式绑定)

③:recvfrom接收信息 以及 sendto发送信息

而TCP则在这基础上,还增加了监听和连接两件事情!

首先监听和连接都是bind之后的事情,其次是先监听再连接,做完这两件事情之后,才会进行信息的发送和接收,所以现在需要理解为什么需要监听和连接!

Q1:为什么要监听和连接?

A1:因为我们TCP特性是面向连接的,所以我们必定需要连接,而需要的连接来自于客户端发出的连接请求,所以我们需要使用监听接口去监听到客户端发送的连接请求,然后才能进行连接!

Q2:连接是谁发起请求,谁接收请求?

A2:连接指的是,服务端在被申请连接,服务器不会主动的连接,而是之前的监听接口监听到了某个客户端发来的连接请求,我们服务器才会执行连接接口进行连接!

Q3:为什么是客户端向服务器申请连接?而不是反过来?

A3:很简单,好比我们日常生活中使用手机,使用APP的本质就是向服务器发送请求,而APP什么时候发送请求完全取决于客户,所以肯定是客户所在的客户端在合适的时候向服务器申请连接!

所以TCP下的服务端和客户端的逻辑如下:

二:TCP编程接口

前提:

①:在TCP中,我们不再使用recvfrom接收信息,sendto发送信息!而是recv和send来接受和发送信息,所以我们着重介绍服务端新增的recv,send,listen(监听),accept(连接),以及客户端新增的connect(发送连接请求)

②:在TCP中,我们最初创建套接字返回的fd,一般命名为listen_fd,叫作监听套接字,为了和后面accept(连接)返回的fd作区分

1:listen

设置套接字为监听状态的函数叫做listen,该函数的函数原型如下:

int listen(int sockfd, int backlog);

参数说明:

  • sockfd:该参数为监听套接字对应的文件描述符(也就是socket接口返回的fd),旨在将套接字fd设置为监听状态,从而进行监听。
  • backlog:本参数目前仅需知道可以设置为5或10或16都可以!其是全连接队列的最大长度,如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10或16都可以。

返回值说明:

  • 监听成功返回0,监听失败返回-1,同时错误码会被设置。

函数意义:

TCP服务器在创建完套接字和绑定后,需要再进一步将套接字fd设置为监听状态,监听是否有新的连接到来。如果监听失败也没必要进行后续操作了,因为监听失败也就意味着TCP服务器无法接收客户端发来的连接请求,因此监听失败我们直接终止程序即可!

其次,你既然要监听,那你肯定需要传参你所创建的套接字,就好比你要请一个保安,你起码得告诉保安这个人需要保护那个房子,然后你让他在这个房子门口站着吧!所以我们socket创建的套接字就是我们服务端的端口,也就是那个门!

2:accept

获取连接的函数叫做accept,该函数的函数原型如下:

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

参数说明:

  • sockfd:特定的监听套接字,表示从该监听套接字中获取连接(socket返回的fd)。
  • addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数。
  • 所以后两个参数,就是客户端的网络属性结构体的信息!

返回值说明:

  • 获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置

函数意义:该接口会让服务端被动接受连接客户端发送的连接!

Q:accept连接成功也会返回一个套接字fd?那accept套接字和监听套接字的区别是什么?

在解释之前需要注意这三种说法都是可以的!

表述准确性解释
"返回套接字"✅ 正确强调这是一个网络套接字
"返回文件描述符"✅ 正确强调这是一个文件描述符
"返回套接字文件描述符"✅ 最准确结合两者特性

A:首先两个套接字的的用途必然是不同的!

举个例子:生活中的餐饮店铺,会有两种员工,一种是张三,负责在店铺门口进行拉客,一种是李四,负责在店铺内服务顾客!所以程序最开始通过socket返回的监听套接字就是张三,而accpet返回的套接字就是李四!

张三(listen_fd):专业拉客,建立连接。
李四们(conn_fds):专业服务,传输数据。

其次,服务器运行后通常只有一个监听套接字,但通过accept返回的连接套接字一般会有多个!这是因为监听套接字绑定的是服务端的端口,就像房屋的唯一入口;而accept返回的连接套接字的数量根据客户端连接数量而变化,就像房屋内部通往不同房间的门。就好比一家餐饮店再大,一般只有一个拉客的,而服务员有多个!

注意:accept函数获取连接时可能会失败,比如客户端退出并且关闭了连接,此时服务端就会获取连接失败,但TCP服务器不会因为获取某个连接失败而退出,而是获取连接失败后应该继续获取连接,但是一定要做的事情是清理掉上次连接所分配的文件描述符,也就是close掉即可!就好比,日常使用APP,我们关闭掉了APP,此时的服务器也不会因为连接不上我们的APP而自己关闭,而是不断的获取并连接其他的客户端!

3:recv和read

接收数据的函数叫做recv,该函数的函数原型如下:

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

参数说明:

  • sockfd:已连接的套接字描述符

  • buf:接收数据的缓冲区

  • len:缓冲区最大长度

  • flags:控制标志(通常为0)

返回值:

  • > 0:实际接收的字节数

  • = 0:对方正常关闭连接

  • < 0:接收出错

常用flags:

MSG_PEEK      // 窥视数据,不从缓冲区移除
MSG_WAITALL   // 阻塞直到收到指定长度的数据
MSG_DONTWAIT  // 非阻塞模式

但在今天我们的代码中,我们用recv或者send时,flags都设置为0,表示使用默认的、最常用的行为,没有任何特殊控制选项,再后面的博客中我们才会详细讲解flags的作用!

当我们的recv或者send的 flags = 0 的时候,其实也可以直接使用write或者read函数,所以在后面的代码中,博主在服务端使用read和wirte来进行读写信息,而在客户端中会使用recv和send!

Q:能用recv和send我理解,这是官方接口,那为什么还能用文件接口read和wirte?

A:

①:因为TCP是流式传输,面向字节流的,而文件接口就是流式传输,面向字节流的,这也是为什么我们使用文件接口的是时候,常常会说"打开一个流","三个默认流"这种话,所以二者是吻合的

②:也可以通过"Linux下,一切皆文件"来理解,既然是文件,当然可以使用文件接口!

回忆一下read函数

ssize_t read(int fd, void *buf, size_t count);

参数说明:

  • fd:特定的文件描述符,表示从该文件描述符中读取数据。
  • buf:数据的存储位置,表示将读取到的数据存储到该位置。
  • count:数据的个数,表示从该文件描述符中读取数据的字节数。

返回值说明:

  • 如果返回值大于0,则表示本次实际读取到的字节个数。
  • 如果返回值等于0,则表示对端已经把连接关闭了。
  • 如果返回值小于0,则表示读取时遇到了错误。

4:send和read

发送数据的函数叫做send,该函数的函数原型如下:

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

参数说明:

  • sockfd:已连接的套接字描述符

  • buf:要发送的数据缓冲区

  • len:要发送的数据长度

  • flags:控制标志

返回值:

  • > 0:实际发送的字节数

  • < 0:发送出错

常用flags:

MSG_DONTWAIT   // 非阻塞发送
MSG_NOSIGNAL   // 禁止SIGPIPE信号
MSG_OOB        // 发送带外数据(紧急数据)

回忆一下write函数

ssize_t write(int fd, const void *buf, size_t count);

参数说明:

  • fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字。
  • buf:需要写入的数据。
  • count:需要写入数据的字节个数。

返回值说明:

  • 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。

5:connect

客户端向服务端发送连接请求的函数叫作connect,函数原型如下:

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数说明:

  • sockfd:特定的套接字,表示通过该套接字发起连接请求。
  • addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen:传入的addr结构体的长度。

返回值说明:

  • 连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。

函数意义:客户端向服务端发送连接请求

三:echo程序

1:单进程版本

这里的单进程指的是:只有一个进程并且该进程是单执行流代码,也叫作单线程

这个代码很经典,所以我会在单进程版本中罗列出所有的代码文件,而在后面的版本,就只会贴出需要修改的部分的代码!

①:TcpServer.hpp

TcpServer.hpp就是服务端代码

#include <iostream>
#include <string>
#include <functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <pthread.h>
#include <sys/types.h>
#include <sys/wait.h>#include "InetAddr.hpp"   //网络属性结构体类
#include "Log.hpp"        //日志文件
#include "ThreadPool.hpp" //线程池文件// 枚举错误的类型
enum
{SOCKET_ERROR = 1, // 创建套接字错误BIND_ERROR,       // 绑定错误LISTEN_ERROR,     // 监听错误USAGE_ERROR       //
};const static int defaultsockfd = -1; // 设定的创建套接字返回的fd的初始值
const static int gbacklog = 16;      // listen接口的第二个参数 默认为16// 服务类
class TcpServer
{
public:// 构造函数            端口号        socket的返回值              是否运行TcpServer(int port) : _port(port), _listensock(defaultsockfd), _isrunning(false){}// 初始化服务端void InitServer(){// 1:创建tcp socket 套接字_listensock = ::socket(AF_INET, SOCK_STREAM, 0); // 和udp不同在于选择的是SOCK_STREAM 因为是面向字节流// 创建套接字失败 打印语句提醒if (_listensock < 0){LOG(FATAL, "socket error");exit(SOCKET_ERROR);}// 创建套接字成功 打印语句提醒LOG(DEBUG, "socket create success, sockfd is : %d\n", _listensock);// 2 填充sockaddr_in结构struct sockaddr_in local;           // 网络通信 所以定义struct sockaddr_in类型的变量memset(&local, 0, sizeof(local));   // 先把结构体清空 好习惯local.sin_family = AF_INET;         // 填写第一个字段 (地址类型)local.sin_port = htons(_port);      // 填写第二个字段PORT (需先转化为网络字节序)local.sin_addr.s_addr = INADDR_ANY; // 填写第三个字段IP (直接填写0即可,INADDR_ANY就是为0的宏)// 3 bind绑定// 我们填充好的local和我们创建的套接字进行绑定(绑定我们接收信息发送信息的端口)int n = ::bind(_listensock, (struct sockaddr *)&local, sizeof(local));// 绑定失败 打印语句提醒if (n < 0){LOG(FATAL, "bind error");exit(BIND_ERROR);}// 绑定成功 打印语句提醒LOG(DEBUG, "bind success, sockfd is : %d\n", _listensock);// 4 监听连接// tcp是面向连接的,所以通信之前,必须先建立连接,而在连接之后 又需要先监听// 监听(第二个参数默认为16 在上文已被设置)n = ::listen(_listensock, gbacklog);// 监听失败 打印语句提醒if (n < 0){LOG(FATAL, "listen error");exit(LISTEN_ERROR);}// 监听成功 打印语句提醒LOG(DEBUG, "listen success, sockfd is : %d\n", _listensock);}// Service(服务函数)// 负责监听连接成功之后的数据的发送与接收void Service(int sockfd, InetAddr client) // 参数为连接accept返回的fd 已经客户端的网络属性结构体{// 来到这里代表监听连接已经成功 打印语句提醒链接的客户端的IP和PORT 以及连接accept返回的fdLOG(DEBUG, "get a new link, info %s:%d, fd : %d\n", client.Ip().c_str(), client.Port(), sockfd);// 因为是echo程序  所以我们先把服务器返回的信息的前缀准备好 也就是标识每个客户的标识符std::string clientaddr = "[" + client.Ip() + ":" + std::to_string(client.Port()) + "]# ";// 开始循环的接收客户端的数据然后返回回去while (true){char inbuffer[1024];                                      // 对方端发来的信息 存储在inbuffer中ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1); // 读取客户端发来的信息 放进inbuffer中// 读取成功if (n > 0){inbuffer[n] = 0;                                  // 先把inbuffer的n下标置为0 使得读取数组内容时及时停止std::cout << clientaddr << inbuffer << std::endl; // 在服务端页面 打印前缀+客户端发来的信息// 通过write返回给客户端的信息 显示在客户端的页面std::string echo_string = "[server echo]# "; //"[server echo]# "表示这是服务端打印的语句echo_string += inbuffer;                     // 再紧跟上客户端发来的信息write(sockfd, echo_string.c_str(), echo_string.size()); // 最后通过write 把 echo_string写回去 客户端那边就会读取到}// 读取失败1 : 客户端退出并且关闭了连接else if (n == 0){LOG(INFO, "%s quit\n", clientaddr.c_str());break; // 则跳出while循环 其清理干净连接产生的fd}// 读取失败1 : 单纯的读取失败else{LOG(ERROR, "read error\n", clientaddr.c_str());break; // 则跳出while循环 其清理干净连接产生的fd}}// 客户端断开连接后的清理工作std::cout << "客户端断开连接,开始清理" << std::endl;::close(sockfd); // 重要:关闭套接字std::cout << "连接已关闭" << std::endl;}// Loop函数// 用于连接void Loop(){_isrunning = true;// 进行连接while (_isrunning){struct sockaddr_in peer; // 用于接收客户端的网络属性结构体socklen_t len = sizeof(peer);// 连接函数 返回的值是server函数的参数 因为这个fd才是进行数据发送和接收的fdint sockfd = ::accept(_listensock, (struct sockaddr *)&peer, &len);// 连接失败if (sockfd < 0){LOG(WARNING, "accept error\n"); // 打印语句提醒continue;}// Version 0 : 一次只能处理一个请求 --- 不可能Service(sockfd, InetAddr(peer));}_isrunning = false;}// 析构函数~TcpServer(){if (_listensock > defaultsockfd)::close(_listensock); // 析构函数 才代表这整个服务器的退出 才会关闭掉最初的监听套接字}private:uint16_t _port;  // 端口号int _listensock; // 监听套接字bool _isrunning; // 是否运行
};

解释:整个服务端分为几个板块:

①:InitServer接口--->创建套接字+bind绑定+listen监听

②:Loop接口--->进行连接+调用server接口

③:server接口--->read接收客户端信息+write返回给客户端信息+客户端退出关闭accept套接字

以上三点概括了我们在上文一直讲解的逻辑,其中的两个套接字,accept套接字在每次客户端退出之后就会执行close关闭掉,本质就是关闭文件描述符,而监听套接字,也就是socket创建的套接字则只有在服务器类的析构函数被调用的时候才会被close掉,这体现了我们之前所说的:连接失败不会让整个服务器类退出,而是等待下一次连接,只有手动ctrl c终止服务器程序,这时候的析构函数才会close关掉监听套接字!

②:Main.cc

Main.cc就是调用服务器类的代码文件!

#include "TcpServer.hpp"
#include <memory>void Usage(std::string proc)
{std::cout << "Usage:\n\t" << proc << " local_port\n"<< std::endl;
}// ./tcpserver port
int main(int argc, char *argv[])
{if (argc != 2){Usage(argv[0]);return 1;}EnableScreen();                                                      // 打印到屏幕上uint16_t port = std::stoi(argv[1]);                                  // 获取使用者输入的端口号std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port); // 创建服务类tsvr->InitServer();                                                  // 初始化(创建套接字 --> 绑定 --> 监听  )tsvr->Loop();                                                        // Loop(连接 --> 调用server执行echo)return 0;
}

解释:

①:运行起来依旧只需要输入一个端口号即可,IP已经内置为0

②:先调用服务器的InitServer在调用Loop完成所有的代码逻辑

③:MainClient.cc

#include <iostream>
#include <string>
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>// 启动客户端 需要用户输入服务端的IP+PORT
void Usage(std::string proc)
{std::cout << "Usage:\n\t" << proc << " serverip serverport\n"<< std::endl;
}// ./tcp_client serverip serverport
int main(int argc, char *argv[])
{if (argc != 3){Usage(argv[0]);exit(1);}std::string serverip = argv[1];           // 从main的参数列表中获取到服务端IPuint16_t serverport = std::stoi(argv[2]); // 从main的参数列表中获取到服务端PORT// 1. 创建socketint sockfd = ::socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0){std::cerr << "socket error" << std::endl;exit(2);}// 2. 无需显式的bind OS会自己做//// 构建目标主机 也就是服务端的网络属性结构体 方便后续的读取和发送数据struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(serverport);server.sin_addr.s_addr = inet_addr(serverip.c_str());// 客户端向服务端 发起连接请求 接口为connectint n = connect(sockfd, (struct sockaddr *)&server, sizeof(server));// 连接失败if (n < 0){std::cerr << "connect error" << std::endl; // 打印语句提醒exit(3);}// 来到这代表连接已经成功 则开始发送和接收信息while (true){std::cout << "Please Enter# "; // 打印请输入提示符std::string outstring;std::getline(std::cin, outstring); // 获取用户在客户端输入的消息 存放进outstringssize_t s = send(sockfd, outstring.c_str(), outstring.size(), 0); // 向服务器发送信息if (s > 0)                                                        // send 发送成功{char inbuffer[1024];ssize_t m = recv(sockfd, inbuffer, sizeof(inbuffer) - 1, 0); // 接收服务器返回的信息if (m > 0)                                                   // recv 接受成功{inbuffer[m] = 0;                    // 在字符串后面置0std::cout << inbuffer << std::endl; // 打印服务器返回的信息 ("[server echo]# " + 我们发送的信息)}else // recv 接受失败{break;}}else // send 发送失败{break;}}::close(sockfd);return 0;
}

解释:客户端的逻辑相较于UDP的仅仅是多了connect接口的调用,以及采用recv和send接口罢了

下面就是一些辅助文件了,比如日志文件,网络属性结构体文件,这些文件都是在之前的博客讲过的,不再赘述

④:Log.hpp

日志博客:https://blog.csdn.net/shylyly_/article/details/151263351

#pragma once#include <iostream>    //C++必备头文件
#include <cstdio>      //snprintf
#include <string>      //std::string
#include <ctime>       //time
#include <cstdarg>     //va_接口
#include <sys/types.h> //getpid
#include <unistd.h>    //getpid
#include <thread>      //锁
#include <mutex>       //锁
#include <fstream>     //C++的文件操作std::mutex g_mutex;                    // 定义全局互斥锁
bool gIsSave = false;                  // 定义一个bool类型 用来判断打印到屏幕还是保存到文件
const std::string logname = "log.txt"; // 保存日志信息的文件名字// 日志等级
enum Level
{DEBUG = 0,INFO,WARNING,ERROR,FATAL
};// 将日志写进文件的函数
void SaveFile(const std::string &filename, const std::string &message)
{std::ofstream out(filename, std::ios::app);if (!out.is_open()){return;}out << message << std::endl;out.close();
}// 日志等级转字符串--->字符串才能表示等级的意义 0123意义不清晰
std::string LevelToString(int level)
{switch (level){case DEBUG:return "Debug";case INFO:return "Info";case WARNING:return "Warning";case ERROR:return "Error";case FATAL:return "Fatal";default:return "Unknown";}
}// 获取当前时间的字符串
// 时间格式包含多个字符 所以干脆糅合成一个字符串
std::string GetTimeString()
{// 获取当前时间的时间戳(从1970-01-01 00:00:00开始的秒数)time_t curr_time = time(nullptr);// 将时间戳转换为本地时间的tm结构体// tm结构体包含年、月、日、时、分、秒等字段struct tm *format_time = localtime(&curr_time);// 检查时间转换是否成功if (format_time == nullptr)return "None";// 缓冲区用于存储格式化后的时间字符串char time_buffer[1024];// 格式化时间字符串:年-月-日 时:分:秒snprintf(time_buffer, sizeof(time_buffer), "%d-%02d-%02d %02d:%02d:%02d",format_time->tm_year + 1900, // tm_year: 从1900年开始的年数,需要加1900format_time->tm_mon + 1,     // tm_mon: 月份范围0-11,需要加1得到实际月份format_time->tm_mday,        // tm_mday: 月中的日期(1-31)format_time->tm_hour,        // tm_hour: 小时(0-23)format_time->tm_min,         // tm_min: 分钟(0-59)format_time->tm_sec);        // tm_sec: 秒(0-60,60表示闰秒)return time_buffer; // 返回格式化后的时间字符串
}// 日志函数-->打印出日志
// 格式:时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数
void LogMessage(int level, std::string filename, int line, bool issave, const char *format, ...)
{std::string levelstr = LevelToString(level); // 得到等级字符串std::string timestr = GetTimeString();       // 得到时间字符串pid_t selfid = getpid();                     // 得到PID// 使用va_接口+vsnprintf得到用户想要的可变参数的字符串 存储与buffer中char buffer[1024];va_list arg;va_start(arg, format);vsnprintf(buffer, sizeof(buffer), format, arg);va_end(arg);std::lock_guard<std::mutex> lock(g_mutex); // 引入C++的RAII的锁 保护打印功能// 保存格式为时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数 的日志信息 到message中std::string message = "[" + timestr + "]" + "[" + levelstr + "]" +"[" + std::to_string(selfid) + "]" +"[" + filename + "]" + "[" + std::to_string(line) + "] " + buffer;// 打印到屏幕if (!issave){std::cout << message << std::endl;}// 保存进文件else{SaveFile(logname, message);}
}// 宏定义 省略掉__FILE__  和 __LINE__
#define LOG(level, format, ...)                                                \do                                                                         \{                                                                          \LogMessage(level, __FILE__, __LINE__, gIsSave, format, ##__VA_ARGS__); \} while (0)// 用户调用则意味着保存到文件
#define EnableScreen() (gIsSave = false)// 用户调用则意味着打印到屏幕
#define EnableFile() (gIsSave = true)

⑤:InetAddr.hpp

InetAddr类很简单,就是接收一个网络属性结构体,然后可以通过成员函数打印出单独的IP或PORT,让一个网络属性结构体更细粒度得被使用

#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>class InetAddr
{
private:void GetAddress(std::string *ip, uint16_t *port){*port = ntohs(_addr.sin_port);*ip = inet_ntoa(_addr.sin_addr);}public:InetAddr(const struct sockaddr_in &addr) : _addr(addr){GetAddress(&_ip, &_port);}std::string Ip(){return _ip;}bool operator == (const InetAddr &addr){// if(_ip == addr._ip)if(_ip == addr._ip && _port == addr._port) // 方便测试{return true;}return false;}struct sockaddr_in Addr(){return _addr;}uint16_t Port(){return _port;}~InetAddr(){}private:struct sockaddr_in _addr;std::string _ip;uint16_t _port;
};

⑥:makefile

.PHONY:all
all:tcpserver tcpclienttcpserver:Main.ccg++ -o $@ $^ -std=c++14 -lpthread
tcpclient:MainClient.ccg++ -o $@ $^ -std=c++14
.PHONY:clean
clean:rm -f tcpserver tcpclient

⑦:运行效果

解释:单进程下且单执行流情况下(单线程),单个客户端是没有问题的!实现了TCP下的网络通信

但是如果此时又有一个客户端向服务器申请连接之后,我们发现我们的第二个客户端输入什么,服务端都没有反应,只有当第一个客户端让退出的时候,此时的服务器端才会一口气把第二个客户端的信息打印出来!如下图:

解释:原因很简单,因为我们今天的TCPecho程序,先建立连接之后,就会进入server接口去死循环地服务该客户端,这意味着如果该客户端不退出,则我们无法跳出server函数,也就无法再次接收到其他客户端发送的连接请求!从而进行连接!

Q:为什么之前的UDPecho没有出现过这种情况?

A:因为UDP是无连接的,它只需要从socket接口得到的套接字,也就是这个端口去获取信息和发送信息,此时不管多少个客户端通过该端口向服务端发送信息,都是可以收到的!

2:多进程版本

①:效果

解释:多个客户端也可以同时连接同一个服务端了!

②:改动代码

代码只用修改Loop函数中的一小部分即可!

    void Loop(){_isrunning = true;// 进行连接while (_isrunning){struct sockaddr_in peer; // 用于接收客户端的网络属性结构体socklen_t len = sizeof(peer);// 连接函数 返回的值是server函数的参数 因为这个fd才是进行数据发送和接收的fdint sockfd = ::accept(_listensock, (struct sockaddr *)&peer, &len);// 连接失败if (sockfd < 0){LOG(WARNING, "accept error\n"); // 打印语句提醒continue;}// Version 1: 采用多进程pid_t id = fork();if (id == 0){// child : 关心sockfd, 不关心listensock::close(_listensock); // 建议if(fork() > 0) exit(0);Service(sockfd, InetAddr(peer)); //孙子进程 -- 孤儿进程 --- 系统领养exit(0);}// father: 关心listensock,不关心sockfd::close(sockfd);waitpid(id, nullptr, 0);    }_isrunning = false;}

注意:创建子进程,是为了让子进程去进行服务,往后,每当增加一个客户端的连接,则增加一个子进程去服务客户端,所以父进程并不会执行server函数,而是等待子进程的退出!所以优秀的写法就是父进程会关闭掉accept的fd,只关心自己的监听fd,而子进程关闭掉监听fd,只关心自己的accept的fd!

解释:首先采用多进程的目的就是想让每个子进程都可以独立的去监听连接等操作,也就是每个子进程都可以独立的服务对应的客户端!这样就可以满足更多的客户端的连接请求,但是要想达到目的,需要先解决一些问题!

Q1:关于回收子进程引发的阻塞问题。多进程本质是通过fork创建子进程,那父进程必然需要等待子进程退出,才能回收子进程 ,这会使得父进程堵塞,那岂不是我们想让每个进程之间独立的进行监听连接等操作这个目的就无法达到了?

因为我们的父进程即使在对应的客户端退出之后,也不一定会立马进行下次的监听连接等操作,而是需要等待子进程退出才能够进行下一次的服务!

而子进程退出的前提仍然是子进程对应的客户端退出了,此时才能继续获取下一个连接请求,那岂不是此时服务端仍然是以一种串行的方式为客户端提供服务??

A1:是的!这就是问题所在!所以我们要避免等待子进程造成的父进程堵塞,从而导致服务端以一种串行的方式为客户端提供服务!

解决方式之一是将waitpid接口调整为非阻塞等待,但不推荐,因为极有可能遗漏掉部分子进程,所以我们选择的做法让子进程再创建一个子进程!让这个孙子进程去执行server函数!

但此时仍未避免阻塞问题,因为我们的子进程需要等到回收孙子进程,父进程需要等到回收子进程

所以我们让子进程创建出孙子进程之后,立马退出 ,这是关键的一步!因为我们的父进程原本是要阻塞等待子进程的,而子进程在创建出孙子进程之后退出了,所以父进程会立马回收子进程,从而父进程就可以独立的不断的服务不同的客户端了!而执行server函数的孙子进程也不会因为回收造成父进程的阻塞,因为孙子进程的父亲,也就是子进程已经早早退出了,所以孙子进程是一个孤儿进程会被系统领养 ,我们无需手动回收!

这个做法是一种让子进程作为桥梁,巧妙利用孤儿进程的原理的解决方法,使得当前存在的进程之间能够独立地去服务客户端!并且此时我们可以有多个客户端同时连接服务器,因为每当有一个客户端申请连接,我们的服务器就会通过子进程创建出一个新的孙子进程才进行服务!

但是进程的创建的代价是巨大的,所以我们下面使用多线程来实现相同的功能呢!

Q2:为什么上面的效果GIF中,两个客户端连接成功之后,我们的客户端页面显示的fd都是4?

A2:

首先,绝对不可能是因为第一个客户端的fd为4已经被及时回收了,从而给第二个客户端去使用,因为我们明明是两个服务端正在一起运行!真正的原因是:我们的子进程让孙子进程执行server函数之后,此子进程会退出,所以我们的父进程在回收子进程的时候,会执行close 代码,从而close掉里面回收链接产生的fd 也就是4,所以下一次连接客户端,使用的fd肯定又是4 ,所以每次创建的临时的子进程继承到的都是4,并且把这个4继承给了孙子进程,所以每次孙子进程打印fd都是4!
 

3:多线程版本

①:效果

②:改动代码

首先,使用多线程就是让每个新线程去执行自己的server即可,我们需要在线程的执行函数的内部去执行server函数!

所以问题就是之前博客谈论过的问题,handler函数在类中的使用,首先handler由于在类中,所以必须是static修饰的,这样才可以符合handler的类型(避免this的干扰),其次我们上次采取的是,handler的参数是this指针,但是今天不行,因为我们的server函数的参数是fd和结构体,这些不是服务器类的成员,而是类中的临时变量,这就意味着,我们handler的参数虽然只有一个,但是该参数需要包含三个东西,所以我们把this,fd,结构体 都封装进一个类ThreadData中即可!在吧ThreadData*作为参数传递给handler!

ThreadData类:

// 线程数据类
// 线程池或者线程实现echo程序的时候 才会需要需要
class ThreadData
{
public:ThreadData(int fd, InetAddr addr, TcpServer *s) : sockfd(fd), clientaddr(addr), self(s){}public:int sockfd;          // 创建连接之后返回的fdInetAddr clientaddr; // 客户端的网络属性结构体TcpServer *self;     // 服务类指针
};

 HandlerSock函数:

    // 每个线程执行的线程函数static void *HandlerSock(void *args) // 参数接收的就是ThreadData *类型的变量{pthread_detach(pthread_self());                   // 线程分离 避免主线程等待新线程 导致无法并行ThreadData *td = static_cast<ThreadData *>(args); // 把参数恢复成ThreadData *类型的变量 用于调用server函数td->self->Service(td->sockfd, td->clientaddr);    // 调用server函数 进行echodelete td;                                        // 回收资源return nullptr;}

 Loop函数:

    // Loop函数// 用于连接void Loop(){_isrunning = true;// 进行连接while (_isrunning){struct sockaddr_in peer; // 用于接收客户端的网络属性结构体socklen_t len = sizeof(peer);// 连接函数 返回的值是server函数的参数 因为这个fd才是进行数据发送和接收的fdint sockfd = ::accept(_listensock, (struct sockaddr *)&peer, &len);// 连接失败if (sockfd < 0){LOG(WARNING, "accept error\n"); // 打印语句提醒continue;}// version 2: 采用多线程pthread_t t;ThreadData *td = new ThreadData(sockfd, InetAddr(peer), this);pthread_create(&t, nullptr, HandlerSock, td); // 将线程分离}_isrunning = false;}

注意:

①:使用线程也会有等待线程从而造成阻塞的问题,所以在每个线程的HandlerSock函数中先进行线程分离即可!

②:多线程之间是共享一份文件描述符表的!这意味着千万不能关闭其他线程的fd,比如主线程的监听套接字,或者其他新线程的acceot的fd!

③:所以并发服务器可以是多线程实现,也可以是多进程实现,并不一定需要各自独立拥有一份文件描述符表才能实现并发!

4:线程池版本

线程池的好处在于,我们实现开辟好了线程,从而不需要等到连接的时候再创建线程,仅此而已

①:改动代码

Loop函数:

// Loop函数// 用于连接void Loop(){_isrunning = true;// 进行连接while (_isrunning){struct sockaddr_in peer; // 用于接收客户端的网络属性结构体socklen_t len = sizeof(peer);// 连接函数 返回的值是server函数的参数 因为这个fd才是进行数据发送和接收的fdint sockfd = ::accept(_listensock, (struct sockaddr *)&peer, &len);// 连接失败if (sockfd < 0){LOG(WARNING, "accept error\n"); // 打印语句提醒continue;}// vesion 3: 采用线程池task_t t = std::bind(&TcpServer::Service, this, sockfd, InetAddr(peer));ThreadPool<task_t>::GetInstance().Push(t);}_isrunning = false;}

服务器类中:

// 线程池的任务需要的类型void()
using task_t = std::function<void()>;

另外再额外引入线程池文件即可!

5:服务器类的总代码

#include <iostream>
#include <string>
#include <functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <pthread.h>
#include <sys/types.h>
#include <sys/wait.h>#include "InetAddr.hpp"   //网络属性结构体类
#include "Log.hpp"        //日志文件
#include "ThreadPool.hpp" //线程池文件// 枚举错误的类型
enum
{SOCKET_ERROR = 1, // 创建套接字错误BIND_ERROR,       // 绑定错误LISTEN_ERROR,     // 监听错误USAGE_ERROR       //
};const static int defaultsockfd = -1; // 设定的创建套接字返回的fd的初始值
const static int gbacklog = 16;      // listen接口的第二个参数 默认为16class TcpServer; // 声明一下服务器类 方便下面的ThreadData的使用// // 线程数据类
// // 线程池或者线程实现echo程序的时候 才会需要需要
// class ThreadData
// {
// public:
//     ThreadData(int fd, InetAddr addr, TcpServer *s) : sockfd(fd), clientaddr(addr), self(s)
//     {
//     }// public:
//     int sockfd;          // 创建连接之后返回的fd
//     InetAddr clientaddr; // 客户端的网络属性结构体
//     TcpServer *self;     // 服务类指针
// };// 线程池的任务需要的类型void()
using task_t = std::function<void()>;// 服务类
class TcpServer
{
public:// 构造函数            端口号        socket的返回值              是否运行TcpServer(int port) : _port(port), _listensock(defaultsockfd), _isrunning(false){}// 初始化服务端void InitServer(){// 1:创建tcp socket 套接字_listensock = ::socket(AF_INET, SOCK_STREAM, 0); // 和udp不同在于选择的是SOCK_STREAM 因为是面向字节流// 创建套接字失败 打印语句提醒if (_listensock < 0){LOG(FATAL, "socket error");exit(SOCKET_ERROR);}// 创建套接字成功 打印语句提醒LOG(DEBUG, "socket create success, sockfd is : %d\n", _listensock);// 2 填充sockaddr_in结构struct sockaddr_in local;           // 网络通信 所以定义struct sockaddr_in类型的变量memset(&local, 0, sizeof(local));   // 先把结构体清空 好习惯local.sin_family = AF_INET;         // 填写第一个字段 (地址类型)local.sin_port = htons(_port);      // 填写第二个字段PORT (需先转化为网络字节序)local.sin_addr.s_addr = INADDR_ANY; // 填写第三个字段IP (直接填写0即可,INADDR_ANY就是为0的宏)// 3 bind绑定// 我们填充好的local和我们创建的套接字进行绑定(绑定我们接收信息发送信息的端口)int n = ::bind(_listensock, (struct sockaddr *)&local, sizeof(local));// 绑定失败 打印语句提醒if (n < 0){LOG(FATAL, "bind error");exit(BIND_ERROR);}// 绑定成功 打印语句提醒LOG(DEBUG, "bind success, sockfd is : %d\n", _listensock);// 4 监听连接// tcp是面向连接的,所以通信之前,必须先建立连接,而在连接之后 又需要先监听// 监听(第二个参数默认为16 在上文已被设置)n = ::listen(_listensock, gbacklog);// 监听失败 打印语句提醒if (n < 0){LOG(FATAL, "listen error");exit(LISTEN_ERROR);}// 监听成功 打印语句提醒LOG(DEBUG, "listen success, sockfd is : %d\n", _listensock);}// Service(服务函数)// 负责监听连接成功之后的数据的发送与接收void Service(int sockfd, InetAddr client) // 参数为连接accept返回的fd 已经客户端的网络属性结构体{// 来到这里代表监听连接已经成功 打印语句提醒链接的客户端的IP和PORT 以及连接accept返回的fdLOG(DEBUG, "get a new link, info %s:%d, fd : %d\n", client.Ip().c_str(), client.Port(), sockfd);// 因为是echo程序  所以我们先把服务器返回的信息的前缀准备好 也就是标识每个客户的标识符std::string clientaddr = "[" + client.Ip() + ":" + std::to_string(client.Port()) + "]# ";// 开始循环的接收客户端的数据然后返回回去while (true){char inbuffer[1024];                                      // 对方端发来的信息 存储在inbuffer中ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1); // 读取客户端发来的信息 放进inbuffer中// 读取成功if (n > 0){inbuffer[n] = 0;                                  // 先把inbuffer的n下标置为0 使得读取数组内容时及时停止std::cout << clientaddr << inbuffer << std::endl; // 在服务端页面 打印前缀+客户端发来的信息// 通过write返回给客户端的信息 显示在客户端的页面std::string echo_string = "[server echo]# "; //"[server echo]# "表示这是服务端打印的语句echo_string += inbuffer;                     // 再紧跟上客户端发来的信息write(sockfd, echo_string.c_str(), echo_string.size()); // 最后通过write 把 echo_string写回去 客户端那边就会读取到}// 读取失败1 : 客户端退出并且关闭了连接else if (n == 0){LOG(INFO, "%s quit\n", clientaddr.c_str());break; // 则跳出while循环 其清理干净连接产生的fd}// 读取失败1 : 单纯的读取失败else{LOG(ERROR, "read error\n", clientaddr.c_str());break; // 则跳出while循环 其清理干净连接产生的fd}}// 客户端断开连接后的清理工作std::cout << "客户端断开连接,开始清理" << std::endl;::close(sockfd); // 重要:关闭套接字std::cout << "连接已关闭" << std::endl;}// // 每个线程执行的线程函数// static void *HandlerSock(void *args) // 参数接收的就是ThreadData *类型的变量// {//     pthread_detach(pthread_self());                   // 线程分离 避免主线程等待新线程 导致无法并行//     ThreadData *td = static_cast<ThreadData *>(args); // 把参数恢复成ThreadData *类型的变量 用于调用server函数//     td->self->Service(td->sockfd, td->clientaddr);    // 调用server函数 进行echo//     delete td;                                        // 回收资源//     return nullptr;// }// Loop函数// 用于连接void Loop(){_isrunning = true;// 进行连接while (_isrunning){struct sockaddr_in peer; // 用于接收客户端的网络属性结构体socklen_t len = sizeof(peer);// 连接函数 返回的值是server函数的参数 因为这个fd才是进行数据发送和接收的fdint sockfd = ::accept(_listensock, (struct sockaddr *)&peer, &len);// 连接失败if (sockfd < 0){LOG(WARNING, "accept error\n"); // 打印语句提醒continue;}// Version 0 : 一次只能处理一个请求 --- 不可能// Service(sockfd, InetAddr(peer));// // Version 1: 采用多进程// pid_t id = fork();// if (id == 0)// {//     // child : 关心sockfd, 不关心listensock//     ::close(_listensock); // 建议//     if(fork() > 0) exit(0);//     Service(sockfd, InetAddr(peer)); //孙子进程 -- 孤儿进程 --- 系统领养//     exit(0);// }// // father: 关心listensock,不关心sockfd// ::close(sockfd);// waitpid(id, nullptr, 0);// version 2: 采用多线程// pthread_t t;// ThreadData *td = new ThreadData(sockfd, InetAddr(peer), this);// pthread_create(&t, nullptr, HandlerSock, td); // 将线程分离// vesion 3: 采用线程池task_t t = std::bind(&TcpServer::Service, this, sockfd, InetAddr(peer));ThreadPool<task_t>::GetInstance().Push(t);}_isrunning = false;}// 析构函数~TcpServer(){if (_listensock > defaultsockfd)::close(_listensock); // 析构函数 才代表这整个服务器的退出 才会关闭掉最初的监听套接字}private:uint16_t _port;  // 端口号int _listensock; // 监听套接字bool _isrunning; // 是否运行
};

LAST_Q:为什么没用使用进程池代码实现并发?

A:因为我们都是先创建线程池或者进程池,而进程池因为被先创建,则无法获取连接时候的fd,但是线程池是共享文件描述符表的,则可以轻松获取,当然你也可以通过某些及其复杂的接口,把fd传给之前的线程池 但是不推荐,太复杂!

最后,此程序是有不足的:

①:比如你连续的运行服务端,因为是同样的端口号,所以后面会bind error,这是正常的,在后面的博客中,会讲到~~~~ ,目前只需换个端口号再运行服务端即可!

②:recv和send都会有发送信息或者接收信息不完整的情况出现,至于为什么会有这种情况以及怎么处理这种情况,后面博客会讲解

http://www.dtcms.com/a/445501.html

相关文章:

  • [人工智能-综述-18]:AI重构千行百业的技术架构
  • gps定位网站建设梧州自助建站seo
  • [论文阅读] AI+教学 | 编程入门课的AI助手革命?ChatGPT的4大核心影响全解析
  • 设计模式学习(五)装饰者模式、桥接模式、外观模式
  • 邵阳网站建设上科互联百度网站如何建设
  • 使用Yocto构建qemu上的Linux系统
  • Scade One 图形建模 - 选择算符模型
  • 【Java SE 异常】原理、处理与实践详解​
  • CPP学习之哈希表
  • Java “并发工具类”面试清单(含超通俗生活案例与深度理解)
  • 2025 AI伦理治理破局:从制度设计到实践落地的探索
  • 力扣1984. 学生分数的最小差值
  • Android studio -kt构建一个app
  • 4.数据类型
  • Spring Boot SSE 流式输出,智能体的实时响应
  • Linux系统性能监控—sar命令
  • PostgreSQL备份不是复制文件?物理vs逻辑咋选?误删还能精准恢复到1分钟前?
  • 网站开发主管招聘wordpress 手机悬浮
  • 描述逻辑对人工智能自然语言处理中深层语义分析的影响与启示
  • 首屏加载耗时从5秒优化到1秒内:弱网与低端安卓机下的前端优化秘笈
  • 【新版】Elasticsearch 8.15.2 完整安装流程(Linux国内镜像提速版)
  • LeetCode 分类刷题:74. 搜索二维矩阵
  • 网站建设项目职责memcache安装wordpress
  • MySQL查看数据表锁定情况
  • sq网站推广用jsp做的网站源代码下载
  • 玩转ClaudeCode:通过Chrome DevTools MCP实现高级调试与反反爬策略
  • 国内做焊接机器人平台网站网络营销的方法是什么
  • 网站建设一般用什么软件敏捷模型是软件开发模型吗
  • 做网站好的品牌泰安房产网签查询
  • No商业网站建设wordpress 调用插件