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

【Linux网络】Socket编程:TCP网络编程

在前面的文章中,我们使用了UDP进行网络编程,这篇文章我们就来使用另一个TCP进行网络编程,我们知道UDP和TCP都是传输层协议,但是特点不同,前者无连接,不可靠传输,面向数据报,后者有连接,可靠传输,面向字节流

文章目录

  • 1. 大体框架
    • 1.1 补充
    • 1.2 TcpServer.cc和TcpServer.hpp框架
  • 2. 服务器初始化
    • 2.1 socket
    • 2.2 bind
    • 2.3 listen
  • 3. 运行服务器
    • 3.1 accept
    • 3.2 多进程版服务器
    • 3.3 多线程版服务器
    • 3.4 线程池版服务器
  • 4. 实现客户端

1. 大体框架

1.1 补充

首先,在之前的UDP网络编程中,我们是直接使用的硬编码,例如退出码直接就设为1、2、3等,显然这并不是一个很好的选择,那么这里我们可以统一设计一个服务器的退出码,就像之前设计日志等级一样,使用枚举常量

我们在Common.hpp文件中定义枚举常量

enum ExitCode
{OK = 0,USAGE_ERR,SOCKET_ERR,BIND_ERR,LISTEN_ERR,CONNECT_ERR,FORK_ERR
};

在后续使用中,我们就能明白这些错误码的含义

另外,我们服务器通常是不能被拷贝的,那我们可以在Common.hpp文件中定义一个不能被拷贝的基类,后面不同的服务器都可以继承这个基类来达到不能被拷贝的目的

class NoCopy
{
public:NoCopy(){}~NoCopy(){}NoCopy(const NoCopy &) = delete;const NoCopy &operator = (const NoCopy&) = delete;
};

1.2 TcpServer.cc和TcpServer.hpp框架

TcpServer.cc:

#include <memory>
#include "TcpServer.hpp"
#include "Common.hpp"using task_t = std::function<void()>;void Usage(std::string proc)
{std::cerr << "Usage: " << proc << " port" << std::endl;
}// ./udpserver port
int main(int argc, char* argv[])
{if(argc != 2){Usage(argv[0]);exit(USAGE_ERR);}uint16_t port = std::stoi(argv[1]);Enable_Console_Log_Strategy();// 网络服务器对象提供网络通信功能std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port);tsvr->Init();tsvr->Run();return 0;
}

TcpServer.hpp:

class TcpServer : public NoCopy
{
public:TcpServer(uint16_t port):_port(port){}void Init(){}void Run(){}~TcpServer() {}
private:uint16_t _port; // 端口号};

2. 服务器初始化

2.1 socket

第一步肯定是创建套接字,不过与UDP不同的是第二个参数,我们套接字类型选择面向连接的可靠字节流

const static int defaultsockfd = -1;class TcpServer : public NoCopy
{
public:TcpServer(uint16_t port):_port(port),_sockfd(defaultsockfd){}void Init(){// 1. 创建套接字_sockfd = socket(AF_INET, SOCK_STREAM, 0);if(_sockfd < 0){LOG(LogLevel::FATAL) << "socket error";exit(SOCKET_ERR);}LOG(LogLevel::INFO) << "socket success";}void Run(){}~TcpServer() {}
private:uint16_t _port; // 端口号int _sockfd;
};

注意:对于使用过的系统调用我们不再详细介绍,可翻看之前UDP网络编程


2.2 bind

第二步就是绑定地址

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

我们现在不需要再自己去填写地址结构的信息,因为我们之前封装了 InetAddr 类,我们直接在 InetAddr 类中再实现一个获取地址结构的函数即可

	const struct sockaddr* NetAddrPtr(){return &_addr;}

由于我们参数中地址结构的类型是 const struct sockaddr ,但是 InetAddr 类中_addr 是 const struct sockaddr_in 类型,所以我们需要类型转换一下

所以我们在Common.hpp文件中实现一个类型转换的宏

#define CONV(addr) ((const struct sockaddr*)&addr)

下面就直接在返回时使用这个宏就可以了

	const struct sockaddr* NetAddrPtr(){return CONV(_addr);}

bind中第三个参数需要知道地址结构的长度,同样 InetAddr 类中再增加一个获取地址结构长度的函数

	socklen_t NetAddrLen(){return sizeof(_addr);}

另外,我们服务端需要监听服务器的所有IP,任何网络接口(网卡)发来的连接我们都愿意接受,所以我们地址结构中的成员 sin_addr 需要设置为 INADDR_ANY,也就是IP地址此时为0,这在UDP网络编程时我们已经详细介绍了原因,这意味着我们服务端在使用 InetAddr 类时只需要传入端口号,那么我们构造函数还需要重载一个供服务端使用

	InetAddr(uint16_t port):_ip("0"), _port(port){// 主机转网络memset(&_addr, 0, sizeof(_addr));_addr.sin_family = AF_INET;_addr.sin_addr.s_addr = INADDR_ANY;_addr.sin_port = htons(_port);}

那绑定地址就简单了,代码如下:

	void Init(){// 1. 创建套接字_sockfd = socket(AF_INET, SOCK_STREAM, 0);if(_sockfd < 0){LOG(LogLevel::FATAL) << "socket error";exit(SOCKET_ERR);}LOG(LogLevel::INFO) << "socket success";// 2. 绑定地址InetAddr local(_port);int n = bind(_sockfd, local.NetAddrPtr(), local.NetAddrLen());if(n < 0){LOG(LogLevel::FATAL) << "bind error";exit(BIND_ERR);}LOG(LogLevel::INFO) << "bind success";}

2.3 listen

第三步与UDP编程不同,我们TCP在绑定之后需要listen

为什么UDP不需要listen,而TCP就需要listen呢?

最根本的区别:面向连接无连接

TCP 的工作方式:为什么需要 listen

TCP的通信过程就像打电话,有一套严格的礼仪。

  1. 服务器准备接听:服务器启动后,它不知道谁会打来电话。它需要做两件事:

    • bind: 告诉系统“我的电话号码是 XXXX”,也就是绑定一个众所周知的端口。

    • listen: 拿起听筒,等待铃响。这个动作就是告诉操作系统:“我准备好了,如果有打给我的电话,请帮我接进来(放入队列)”。

  2. 客户端拨号:客户端调用 connect,这就像“拨号”,发起TCP三次握手。

  3. 服务器应答:服务器的操作系统内核收到“铃响”(SYN包),完成三次握手,然后将这个已建立的连接放入一个队列中。

  4. 服务器接听:服务器调用 accept,这就像“按下接听键”,从队列中取出一个已经建立的连接,开始正式通话。

listen 的核心作用就是创建那个“等待接听的电话队列”。没有 listen,即使客户端尝试连接,服务器的操作系统也不知道该如何处理这个连接请求,会直接拒绝(RST包)。

注意:这里涉及到的三次握手等,我们后面在介绍传输层TCP协议时会详细介绍这些相关内容,目前我们暂只需要学会TCP网络编程的相关系统调用,后续会慢慢介绍其它

UDP 的工作方式:为什么不需要 listen

UDP的通信过程就像寄明信片。

  1. 服务器准备接收:服务器启动后,它只需要做一件事:

    • bind: 告诉系统“我的收件地址是 XXXX”,也就是绑定一个端口。这样,邮差(操作系统)才知道把寄往这个地址的明信片送给你。
  2. 客户端发送:客户端直接使用 sendto,在明信片上写好内容、收件人地址(服务器IP和端口),然后扔进邮筒。不需要事先通知服务器“我要给你寄明信片了”。

  3. 服务器接收:服务器调用 recvfrom,这就像“查看信箱”,看看有没有新的明信片。它可以从任何客户端接收明信片,无需为每个客户端建立独立的“连接”。

UDP 没有“连接”的概念,因此:

  • 不需要 listen 来创建连接队列。

  • 不需要 accept 来接受一个连接。

  • 不需要 connect(在客户端,connect 在UDP中是可选的,它只是设置一个默认的目标地址,而非建立连接)。

内核层面的视角

  • TCP内核:维护着复杂的连接状态机(如 LISTEN, SYN_RCVD, ESTABLISHED)和连接队列。listen 是触发状态从 CLOSED 变为 LISTEN 的关键调用。

  • UDP内核:几乎没有状态。它只维护一个简单的接收缓冲区。当数据报到来时,它检查目标端口是否被某个套接字绑定,如果是,就将数据报放入该套接字的缓冲区。

listen系统调用

listen 系统调用将一个已绑定的套接字置于“被动监听”状态,使其能够接受来自客户端的连接请求。它本身并不接受连接,而是为后续的 accept 调用做准备,并设置连接请求队列的长度。

简单来说,它的作用是:“告诉操作系统,我这个套接字已经准备好接受连接了,如果有客户端来连接,请先把它们安排在这个队列里。”

#include <sys/socket.h>int listen(int sockfd, int backlog);

参数详解

  1. int sockfd
  • 含义: 套接字描述符,由 socket 系统调用返回。

  • 要求: 在调用 listen 之前,该套接字必须已经使用 bind 系统调用与一个本地地址(IP地址和端口号)关联起来。

  1. int backlog
  • 含义: 连接请求队列的最大长度。

  • 作用: 当多个客户端同时发起连接请求时,服务器可能来不及立即调用 accept 处理。操作系统内核会维护一个队列来存放这些“已完成TCP三次握手,但尚未被服务器 accept ”的连接。backlog 参数就是这个队列的最大长度。

  • 细节: 这个参数的含义在历史上有些变化,但现在通常的理解是:

    • 它指的是已完成三次握手(即 ESTABLISHED 状态)、等待 accept 取走的连接的最大数目。

    • 内核中维护的队列可能实际上分为两个部分:

      • 未完成连接队列: 存放收到SYN包,但尚未完成三次握手的连接。

      • 已完成连接队列: 存放已完成三次握手的连接。

    • backlog 参数现在通常指的是已完成连接队列的大小。

  • 如何设置

    • 可以设置为 SOMAXCONN(定义在 sys/socket.h 中),让系统使用一个默认的、相对较大的合理值(在Linux上,通常可以通过 /proc/sys/net/core/somaxconn 文件查看和修改这个默认值)。

    • 对于高并发服务器,通常会将其设置为一个较大的值(如1024或更高),但最终生效的值是 min(backlog, somaxconn)。

返回值

  • 成功: 返回 0。

  • 失败: 返回 -1,并设置相应的错误码 errno。常见的错误有:

    • EBADF: sockfd 不是有效的文件描述符。

    • EINVAL: 套接字未调用 bind 进行绑定,或者该套接字不是 SOCK_STREAM 类型。

    • ENOTSOCK: sockfd 不是一个套接字描述符。

这里我们自己设置一个

const static int backlog = 8;

代码如下:

	void Init(){// 1. 创建套接字_sockfd = socket(AF_INET, SOCK_STREAM, 0);if(_sockfd < 0){LOG(LogLevel::FATAL) << "socket error";exit(SOCKET_ERR);}LOG(LogLevel::INFO) << "socket success";// 2. 绑定地址InetAddr local(_port);int n = bind(_sockfd, local.NetAddrPtr(), local.NetAddrLen());if(n < 0){LOG(LogLevel::FATAL) << "bind error";exit(BIND_ERR);}LOG(LogLevel::INFO) << "bind success";// 3. 设置socket状态为listenn = listen(_sockfd, backlog);if (n < 0){LOG(LogLevel::FATAL) << "listen error";exit(LISTEN_ERR);}LOG(LogLevel::INFO) << "listen success: " << _sockfd;}

3. 运行服务器

还是和之前一样使用运行标志位来表示运行状态

const static int defaultsockfd = -1;
const static int backlog = 8;
class TcpServer : public NoCopy
{
public:TcpServer(uint16_t port):_port(port),_sockfd(defaultsockfd),_isrunning(false){}void Init(){// 1. 创建套接字_sockfd = socket(AF_INET, SOCK_STREAM, 0);if(_sockfd < 0){LOG(LogLevel::FATAL) << "socket error";exit(SOCKET_ERR);}LOG(LogLevel::INFO) << "socket success";// 2. 绑定地址InetAddr local(_port);int n = bind(_sockfd, local.NetAddrPtr(), local.NetAddrLen());if(n < 0){LOG(LogLevel::FATAL) << "bind error";exit(BIND_ERR);}LOG(LogLevel::INFO) << "bind success";// 3. 设置socket状态为listenn = listen(_sockfd, backlog);if (n < 0){LOG(LogLevel::FATAL) << "listen error";exit(LISTEN_ERR);}LOG(LogLevel::INFO) << "listen success: " << _sockfd;}void Run(){_isrunning = true;while(_isrunning){}_isrunning = false;}~TcpServer() {}
private:uint16_t _port; // 端口号int _sockfd;bool _isrunning;
};

3.1 accept

accept 系统调用从已完成连接队列中取出第一个连接请求,创建一个新的套接字用于与客户端通信,并返回这个新套接字的文件描述符。

核心理解

  • listen 只是开启监听并设置队列

  • accept 才是真正接受连接并创建通信通道

  • 监听套接字继续监听,通信套接字负责数据传输

#include <sys/socket.h>int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

参数详解

  1. int sockfd
  • 含义:监听套接字描述符(由 socket 创建,经过 bind 和 listen)

  • 作用:从这个套接字的已完成连接队列中获取连接

  1. struct sockaddr *addr
  • 含义:指向存放客户端地址信息的缓冲区

  • 作用:如果非NULL,系统会将连接客户端的地址信息(IP、端口)填充到这个结构中

  • 类型:通常使用 struct sockaddr_in(IPv4)或 struct sockaddr_in6(IPv6)

  1. socklen_t *addrlen
  • 含义:输入输出参数

  • 输入:调用前应设置为 addr 指向缓冲区的大小

  • 输出:返回时被设置为实际存储的地址结构的长度

  • 重要:调用前必须初始化,否则可能得到错误结果

返回值

  • 成功:返回一个新的套接字描述符(≥0),专门用于与这个特定客户端通信

  • 失败:返回 -1,并设置相应的 errno

我们socket创建套接字不是已经返回一个 “文件描述符” 了嘛,为什么accept也会返回一个 “文件描述符” 呢?两者有什么区别吗?

想象一个银行:

  • socket() 创建的描述符 = 银行大门

  • accept() 返回的描述符 = 柜台窗口

银行大门(监听套接字)

  • 只有一个,整个银行只有一个主入口

  • 作用:让客户进入银行大厅排队

  • 不办理业务:大门本身不处理存款、取款

  • 长期存在:银行营业期间一直敞开

柜台窗口(连接套接字)

  • 有多个,可以同时服务多个客户

  • 作用:专门为特定客户办理具体业务

  • 临时性:客户办完业务就关闭窗口

  • 一对一服务:每个窗口只服务一个客户

技术层面

  1. 监听套接字(由 socket() 返回)‍
  • 作用:用于监听客户端的连接请求,不直接传输数据
  • 生命周期:在服务器整个运行期间通常保持打开状态,持续接受新连接
  • 状态:处于 LISTEN 状态,等待连接请求
  1. 连接套接字(由 accept() 返回)‍
  • 作用:代表与特定客户端的已建立连接,用于实际的数据读写
  • 生命周期:仅在对应客户端连接期间存在,连接关闭后该描述符失效
  • 状态:处于 ESTABLISHED 状态,可直接进行 I/O 操作

关键区别

特性socket() 返回的描述符accept() 返回的描述符
角色监听器(接受新连接)连接端点(与客户端通信)
数量通常一个每个客户端连接一个
数据传递不直接传输数据直接读写数据
关联对象服务器地址和端口特定客户端的 IP 和端口

为什么需要两个描述符?

这种设计实现了并发处理:服务器用一个监听描述符持续接受新请求,同时为每个已连接客户端创建独立的描述符处理数据交换,互不干扰。

所以socket返回的是监听套接字,那我们可以将成员变量_sockfd修改为_listensockfd增加代码可读性,然后accept接受客户端的连接

代码如下:


const static int defaultsockfd = -1;
const static int backlog = 8;
class TcpServer : public NoCopy
{
public:TcpServer(uint16_t port):_port(port),_listensockfd(defaultsockfd),_isrunning(false){}void Init(){// 1. 创建套接字_listensockfd = socket(AF_INET, SOCK_STREAM, 0);if(_listensockfd < 0){LOG(LogLevel::FATAL) << "socket error";exit(SOCKET_ERR);}LOG(LogLevel::INFO) << "socket success";// 2. 绑定地址InetAddr local(_port);int n = bind(_listensockfd, local.NetAddrPtr(), local.NetAddrLen());if(n < 0){LOG(LogLevel::FATAL) << "bind error";exit(BIND_ERR);}LOG(LogLevel::INFO) << "bind success";// 3. 设置socket状态为listenn = listen(_listensockfd, backlog);if (n < 0){LOG(LogLevel::FATAL) << "listen error";exit(LISTEN_ERR);}LOG(LogLevel::INFO) << "listen success: " << _listensockfd;}void Run(){_isrunning = true;while(_isrunning){struct sockaddr_in peer;socklen_t len = sizeof(peer);// 如果没有连接,accept就会阻塞int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);if(sockfd < 0){LOG(LogLevel::WARNING) << "accept errpr";continue;}InetAddr addr(peer);LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();}_isrunning = false;}~TcpServer() {}
private:uint16_t _port; // 端口号int _listensockfd;bool _isrunning;
};

接受客户端的连接后,就可以对客户端发送给服务端的数据进行处理,我们可以先来写一个简单的EchoServer服务

和UDP一样的步骤,我们需要先读取客户端发送的数据,然后再写回,因为tcp已经和客户端建立好连接了,所以不需要和UDP一样每次收发数据都需要完整的地址信息,而且tcp和文件操作一样,都是面向字节流的,所以我们可以使用read/write来读写数据

	void Service(int sockfd, InetAddr& addr){char buffer[1024];while(true){// 1. 读取数据// a. n>0: 读取成功// b. n<0: 读取失败// c. n==0: 对端把链接关闭了,读到了文件的结尾ssize_t n = read(sockfd, buffer, sizeof(buffer)-1);if(n > 0){buffer[n] = 0;LOG(LogLevel::DEBUG) << addr.StringAddr() << "# " << buffer; // 2. 写回数据std::string echo_string = "echo# ";echo_string += buffer;write(sockfd, echo_string.c_str(), echo_string.size());}else if(n == 0){LOG(LogLevel::DEBUG) << addr.StringAddr() << "退出了...";close(sockfd);break;}else{LOG(LogLevel::DEBUG) << addr.StringAddr() << "异常...";close(sockfd);break;}}}void Run(){_isrunning = true;while(_isrunning){struct sockaddr_in peer;socklen_t len = sizeof(peer);// 如果没有连接,accept就会阻塞int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);if(sockfd < 0){LOG(LogLevel::WARNING) << "accept errpr";continue;}InetAddr addr(peer);LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();// v0——EchoServerService(sockfd, addr);}_isrunning = false;}

但是这里有个问题,当一个客户与服务端连接后进行读写数据时,此时服务端就会执行Service函数,但是这个时候如果再来一个或多个客户与服务端进行连接时,服务端是不能accept连接客户端的,因为服务端是单进程在执行Service函数,也就是只要当前客户与服务端的连接没有断开,那么服务端就会一直死循环进行收发数据,所以其他客户就不能与服务端建立连接

那要怎么做呢?


3.2 多进程版服务器

我们可以使用多进程,创建一个子进程去执行任务,父进程则不断与客户端建立连接

问题1:进程如果退出了,曾经打开的文件会怎么办?

默认会被自动释放掉,fd,会自动被关闭,close(fd)

问题2:进程如果打开了一个文件,得到了一个fd,如果在创建子进程,这个子进程能拿到父进程的fd进行访问吗?

能,之前学习的管道不就是吗,fork创建子进程,然后分别关闭父子进程的读写端,这不就是子进程拿到了父进程的fd来进行访问吗

	void Run(){_isrunning = true;while(_isrunning){struct sockaddr_in peer;socklen_t len = sizeof(peer);// 如果没有连接,accept就会阻塞int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);if(sockfd < 0){LOG(LogLevel::WARNING) << "accept errpr";continue;}InetAddr addr(peer);LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();// v0——EchoServer// Service(sockfd, addr);// v1——EchoServer 多进程版pid_t id = fork();if(id < 0){LOG(LogLevel::FATAL) << "fork error";exit(FORK_ERR);}else if(id == 0){// 子进程// 我们不想让子进程访问listensock!close(_listensockfd);Service(sockfd, addr);exit(OK);}else{// 父进程close(sockfd);//父进程是不是要等待子进程啊,要不然僵尸了??pid_t rid = waitpid(id, nullptr, 0); // 阻塞的吗?(void)rid;}}_isrunning = false;}

这里我们父进程需要等待子进程退出,要不然子进程会成为僵尸进程,可是我们这里是阻塞等待啊,那服务端还是不能去连接其他客户啊,这怎么办?

首先,我们在学习信号时,提到过子进程退出时会给父进程发送 SIGCHLD 信号,父进程可以通过捕获此信号来调用wait/waitpid回收子进程。

那这里推荐做法就是父进程可以显式忽略该信号,这样父进程就可以继续执行自己的任务,完全不需要调用wait/waitpid,因为OS内核会自己清理回收子进程资源

这里还有另一个推荐的做法,就是子进程再次fork创建子进程,然后子进程立即退出,留下孙子进程来执行任务,孙子进程此时会成为孤儿进程,由操作系统管理回收,那么父进程就不会再阻塞了,因为子进程已经退出了

	void Run(){_isrunning = true;while(_isrunning){struct sockaddr_in peer;socklen_t len = sizeof(peer);// 如果没有连接,accept就会阻塞int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);if(sockfd < 0){LOG(LogLevel::WARNING) << "accept errpr";continue;}InetAddr addr(peer);LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();// v0——EchoServer// Service(sockfd, addr);// v1——EchoServer 多进程版pid_t id = fork();if(id < 0){LOG(LogLevel::FATAL) << "fork error";exit(FORK_ERR);}else if(id == 0){// 子进程// 我们不想让子进程访问listensock!close(_listensockfd);if(fork() > 0) // 再次fork,子进程直接退出exit(OK);Service(sockfd, addr); // 孙子进程来执行任务,但是孙子进程会成为孤儿进程,由系统来回收exit(OK);}else{// 父进程close(sockfd);//父进程是不是要等待子进程啊,要不然僵尸了??pid_t rid = waitpid(id, nullptr, 0); // 阻塞的吗?不会,因为子进程立马退出了(void)rid;}}_isrunning = false;}

3.3 多线程版服务器

那除了多进程,我们当然还可以使用多线程了,这里我们先使用原生的线程来实现多线程版

代码如下:

	class ThreadData{public:ThreadData(int fd, InetAddr &addr, TcpServer *tsvr) :_sockfd(fd), _addr(addr), _tsvr(tsvr){}public:int _sockfd;InetAddr _addr;TcpServer *_tsvr;};static void* Routine(void* args){pthread_detach(pthread_self());ThreadData* td = static_cast<ThreadData*>(args);td->_tsvr->Service(td->_sockfd, td->_addr);delete td;return nullptr;}void Run(){_isrunning = true;while(_isrunning){struct sockaddr_in peer;socklen_t len = sizeof(peer);// 如果没有连接,accept就会阻塞int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);if(sockfd < 0){LOG(LogLevel::WARNING) << "accept errpr";continue;}InetAddr addr(peer);LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();// v0——EchoServer// Service(sockfd, addr);// v1——EchoServer 多进程版// pid_t id = fork();// if(id < 0)// {//     LOG(LogLevel::FATAL) << "fork error";//     exit(FORK_ERR);// }// else if(id == 0)// {//     // 子进程//     // 我们不想让子进程访问listensock!//     close(_listensockfd);//     if(fork() > 0) // 再次fork,子进程直接退出//         exit(OK);//     Service(sockfd, addr); // 孙子进程来执行任务,但是孙子进程会成为孤儿进程,由系统来回收//     exit(OK);// }// else// {//     // 父进程//     close(sockfd);//     //父进程是不是要等待子进程啊,要不然僵尸了??//     pid_t rid = waitpid(id, nullptr, 0); // 阻塞的吗?不会,因为子进程立马退出了//     (void)rid;// }// v2——EchoServer 多线程版ThreadData* td = new ThreadData(sockfd, addr, this);pthread_t tid;pthread_create(&tid, nullptr, Routine, td);}_isrunning = false;}

这里类内成员函数隐含this指针,所以要使用静态成员函数,但静态成员函数不能访问非静态成员变量,所以我们使用了一个内部类 ThreadData ,并且传入this指针,方便我们使用类内成员变量,这些我们在学习线程时也介绍过,就不再多做解释

同时我们线程也需要等待,那要等待的话不就又会阻塞在这里了,所以我们在创建线程时就分离线程,就不需要等待线程了


3.4 线程池版服务器

说到多线程,当然就能想到线程池,所以我们还可以实现一个线程池版

#pragma once#include "Log.hpp"
#include "InetAddr.hpp"
#include "ThreadPool.hpp"
#include "Common.hpp"
#include <sys/wait.h>
#include <pthread.h>using namespace LogModule;
using namespace ThreadPoolModule;using task_t = std::function<void()>;const static int defaultsockfd = -1;
const static int backlog = 8;
class TcpServer : public NoCopy
{
public:TcpServer(uint16_t port):_port(port),_listensockfd(defaultsockfd),_isrunning(false){}void Init(){// 1. 创建套接字_listensockfd = socket(AF_INET, SOCK_STREAM, 0);if(_listensockfd < 0){LOG(LogLevel::FATAL) << "socket error";exit(SOCKET_ERR);}LOG(LogLevel::INFO) << "socket success";// 2. 绑定地址InetAddr local(_port);int n = bind(_listensockfd, local.NetAddrPtr(), local.NetAddrLen());if(n < 0){LOG(LogLevel::FATAL) << "bind error";exit(BIND_ERR);}LOG(LogLevel::INFO) << "bind success";// 3. 设置socket状态为listenn = listen(_listensockfd, backlog);if (n < 0){LOG(LogLevel::FATAL) << "listen error";exit(LISTEN_ERR);}LOG(LogLevel::INFO) << "listen success: " << _listensockfd;}void Service(int sockfd, InetAddr& addr){char buffer[1024];while(true){// 1. 读取数据// a. n>0: 读取成功// b. n<0: 读取失败// c. n==0: 对端把链接关闭了,读到了文件的结尾ssize_t n = read(sockfd, buffer, sizeof(buffer)-1);if(n > 0){buffer[n] = 0;LOG(LogLevel::DEBUG) << addr.StringAddr() << "# " << buffer; // 2. 写回数据std::string echo_string = "echo# ";echo_string += buffer;write(sockfd, echo_string.c_str(), echo_string.size());}else if(n == 0){LOG(LogLevel::DEBUG) << addr.StringAddr() << "退出了...";close(sockfd);break;}else{LOG(LogLevel::DEBUG) << addr.StringAddr() << "异常...";close(sockfd);break;}}}class ThreadData{public:ThreadData(int fd, InetAddr &addr, TcpServer *tsvr) :_sockfd(fd), _addr(addr), _tsvr(tsvr){}public:int _sockfd;InetAddr _addr;TcpServer *_tsvr;};static void* Routine(void* args){pthread_detach(pthread_self());ThreadData* td = static_cast<ThreadData*>(args);td->_tsvr->Service(td->_sockfd, td->_addr);delete td;return nullptr;}void Run(){_isrunning = true;while(_isrunning){struct sockaddr_in peer;socklen_t len = sizeof(peer);// 如果没有连接,accept就会阻塞int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);if(sockfd < 0){LOG(LogLevel::WARNING) << "accept errpr";continue;}InetAddr addr(peer);LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();// v0——EchoServer// Service(sockfd, addr);// v1——EchoServer 多进程版// pid_t id = fork();// if(id < 0)// {//     LOG(LogLevel::FATAL) << "fork error";//     exit(FORK_ERR);// }// else if(id == 0)// {//     // 子进程//     // 我们不想让子进程访问listensock!//     close(_listensockfd);//     if(fork() > 0) // 再次fork,子进程直接退出//         exit(OK);//     Service(sockfd, addr); // 孙子进程来执行任务,但是孙子进程会成为孤儿进程,由系统来回收//     exit(OK);// }// else// {//     // 父进程//     close(sockfd);//     //父进程是不是要等待子进程啊,要不然僵尸了??//     pid_t rid = waitpid(id, nullptr, 0); // 阻塞的吗?不会,因为子进程立马退出了//     (void)rid;// }// v2——EchoServer 多线程版// ThreadData* td = new ThreadData(sockfd, addr, this);// pthread_t tid;// pthread_create(&tid, nullptr, Routine, td);// v3——EchoServer 线程池版ThreadPool<task_t>::GetInstance()->Enqueue([this, sockfd, &addr](){this->Service(sockfd, addr);});}_isrunning = false;}~TcpServer() {}
private:uint16_t _port; // 端口号int _listensockfd;bool _isrunning;
};

注意:我们线程池是固定5个线程,所以我们最多只有五个客户端与服务端进行连接

这是因为我们Service函数是在死循环执行,这种我们可以称之为长服务,与之对应,那肯定就还有短服务,什么意思呢?下面我们来认识一下短服务和长服务

  • 短服务​:其核心是“按需连接”。每次数据交互都遵循“建立连接 → 传输数据 → 关闭连接”的流程。就像你去银行柜台,每办理一项业务都需要重新排队一次。这种方式的好处是服务端无需长期维护大量连接状态,结构简单;缺点是频繁的“三次握手”和“四次挥手”会带来额外的网络开销和延迟。HTTP/1.0是短服务的典型代表。

  • ​长服务​:其核心是“连接复用”。一旦连接建立,就会在一定时间内保持开启,期间可以进行多次数据传输,通常还会引入心跳机制​(定期发送小数据包)来检测连接的活性。这就像一次排队后,可以连续办理多项业务,效率更高。这种方式显著减少了重复建立连接的开销,适合实时应用;但需要服务端投入更多资源来管理和维持这些长连接。

一般多进程多线程比较适合长服务,线程池适合短服务,但也不是绝对的,这里我们只简单提一下,当然也可以将死循环改一下,那客户端数量就不会仅限于5个了,感兴趣可以自己下来尝试一下


4. 实现客户端

客户端实现其实和udp网络编程时大差不差

代码如下:

#include "Common.hpp"
#include "InetAddr.hpp"void Usage(std::string proc)
{std::cerr << "Usage: " << proc << " server_ip server_port" << std::endl;
}// ./tcpclient server_ip server_port
int main(int argc, char *argv[])
{// 客户端需要绑定服务器的ip和portif (argc != 3){Usage(argv[0]);exit(USAGE_ERR);}std::string server_ip = argv[1];uint16_t server_port = std::stoi(argv[2]);int sockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0){std::cerr << "socket error" << std::endl;exit(SOCKET_ERR);}// 2. bind吗??需要。显式的bind?不需要!随机方式选择端口号// 2. 我应该做什么呢?listen?accept?都不需要!!// 2. 直接向目标服务器发起建立连接的请求return 0;
}

我们这里也不需要显式bind,关于原因我们在udp网络编程时已经说明了,那我们应该做什么呢?需要和服务端一样listen或者accept吗?不需要,因为服务端就相当于是接电话的人,所以服务端需要监听和接受连接,而我们客户端则相当于打电话的人,那肯定是需要向服务端发起连接,即需要connect

connect系统调用详解

connect 系统调用用于客户端向服务器发起连接请求(TCP)或设置默认对端地址(UDP)。

核心作用

  • TCP:发起三次握手,建立可靠连接

  • UDP:设置默认目标地址,之后可用 read/write

#include <sys/socket.h>int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数详解

  1. int sockfd
  • 套接字描述符,由 socket() 创建

  • 对于TCP必须是 SOCK_STREAM 类型

  • 对于UDP必须是 SOCK_DGRAM 类型

  1. const struct sockaddr *addr
  • 指向服务器地址结构体的指针

  • 包含服务器IP地址和端口号

  • 通常是 struct sockaddr_in(IPv4)或 struct sockaddr_in6(IPv6)

  1. socklen_t addrlen
  • 地址结构体的长度

  • 例如:sizeof(struct sockaddr_in)

返回值

  • 成功:返回 0

  • 失败:返回 -1,并设置相应的 errno

代码如下:

#include "Common.hpp"
#include "InetAddr.hpp"void Usage(std::string proc)
{std::cerr << "Usage: " << proc << " server_ip server_port" << std::endl;
}// ./tcpclient server_ip server_port
int main(int argc, char *argv[])
{// 客户端需要绑定服务器的ip和portif (argc != 3){Usage(argv[0]);exit(USAGE_ERR);}std::string server_ip = argv[1];uint16_t server_port = std::stoi(argv[2]);int sockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0){std::cerr << "socket error" << std::endl;exit(SOCKET_ERR);}// 2. bind吗??需要。显式的bind?不需要!随机方式选择端口号// 2. 我应该做什么呢?listen?accept?都不需要!!// 2. 直接向目标服务器发起建立连接的请求InetAddr serveraddr(server_ip, server_port);int n = connect(sockfd, serveraddr.NetAddrPtr(), serveraddr.NetAddrLen());if(n < 0){std::cerr << "connect error" << std::endl;exit(CONNECT_ERR);}// 3. echo clientwhile(true){// 从键盘获取输入std::string line;std::cout << "Please Enter# ";std::getline(std::cin, line);write(sockfd, line.c_str(), line.size());char buffer[1024];ssize_t size = read(sockfd, buffer, sizeof(buffer)-1);if(size > 0){buffer[size] = 0;std::cout << "sercer echo# " << buffer << std::endl;}}return 0;
}

运行结果:
在这里插入图片描述
我们网络服务已经完成了,上层服务我们可以和UDP网络编程一样,直接在服务端主程序调用其他的服务,就比如之前实现的翻译和路由转发,然后在服务端接收数据时,将数据回调处理,最后将结果写回客户端。不过这里我们就不实现了,因为和UDP是一样的。

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

相关文章:

  • 离线docker安装jupyter(python网页版编辑器)
  • 自己怎么做彩票网站吗网站建设招标2017
  • 达梦守护集群部署安装
  • 农村电子商务网站建设wordpress不能安装插件
  • 每天五分钟深度学习:两个角度解释正则化解决网络过拟合的原理
  • 【Android Gradle学习笔记】第二天:Gradle工程目录结构
  • 【知识拓展Trip Six】宿主OS是什么,传统虚拟机和容器又有什么区别?
  • AI眼镜:作为人机交互新范式的感知延伸与智能融合终端
  • 开发网站 语言卡片式网站
  • 长乐市住房和城乡建设局网站在线购物商城网站建设
  • qt5.14查看调试源码
  • 深度学习实战:Python水果识别 CNN算法 卷积神经网络(TensorFlow训练+Django网页源码)✅
  • J1939基础通信
  • 前端开发与后端开发的区别是什么?
  • 模块使用教程(基于STM32)——蓝牙模块
  • BaseLine与BackBone
  • 多视图几何--密集匹配--视差平面推导
  • 官网和商城结合的网站网站推广合同模板
  • 微软新模型UserLM:如何为AI助手打造一个“真实世界”模拟器
  • Linux中页面分配alloc_pages相关函数
  • Qt---布局管理器
  • 基于单片机的图书馆智能座位管理平台
  • 中国机械工业建设集团有限公司网站高端网站建设论坛
  • Envoy Gateway + ext_authz 做“入口统一鉴权”,ABP 只做资源执行
  • vscode免密码认证ssh连接virtual box虚拟机
  • 3.6 JSON Mode与JSON Schema
  • React Native::关于react的匿名函数
  • 基于JETSON ORIN+FPGA+GMSL AI相机的工业双目视觉感知方案
  • 常规的鱼眼镜头有哪些类型?能做什么?
  • 虚实之间:AR/VR开发中的性能优化艺术