网络编程 socket——TCP

目录

TCP网络编程

前期准备

服务器构造函数

服务端创建套接字

服务端绑定

InetAddr类

服务端监听

服务端接收连接测试

服务端处理请求

客户端创建套接字

服务器测试

单执行流服务器的弊端

多进程版的TCP网络程序


TCP网络编程

前期准备

在我们编写的服务器程序中,我们不希望服务器能被拷贝或者复制粘贴,我们可以把创建的服务器类的赋值与拷贝私有化,但这里我推荐写一个NoCopy基类,在基类中禁止拷贝构造和赋值重载,这样我们派生类中就可以避免拷贝和赋值了(如果这部分知识忘了请点击这篇博客继承)。这样写就十分便捷,我们在UDP中也可以使用。

所有的LOG()是日志信息,我们在日志的实现中完成的

注:这是后文包含的文件Common.hpp

cpp 复制代码
class NoCopy
{
public:
    NoCopy(){}
    ~NoCopy(){}
    NoCopy(const NoCopy &) = delete;
    const NoCopy &operator = (const NoCopy&) = delete;
};

#define CONV(addr) ((struct sockaddr*)&addr)
enum ExitCode
{
    OK = 0,
    USAGE_ERR,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    CONNECT_ERR,
    FORK_ERR
};

服务器构造函数

一个服务器的类需要什么?无疑是ip、端口,需要的文件描述符表,通常还会有一个回调函数来处理不同的需求。注:我们期望的服务器是不限定IP地址

cpp 复制代码
class TcpServer : public NoCopy
{
public:
    TcpServer(uint16_t port,func_t func) : _port(port), _listensockfd(defaultsockfd), _func(func),_isrunning(false)
    {
    }
  
    ~TcpServer()
    {
        if (_sock >= 0){
			close(_sock);
		}
    }

private:
    uint16_t _port;
    int _listensockfd; // 监听socket
    bool _isrunning;
    func_t _func; // 设置回调处理
};

服务端创建套接字

我们将TCP服务器封装成一个类,当我们定义出一个服务器对象后需要马上对服务器进行初始化,而初始化TCP服务器要做的第一件事就是创建套接字

TCP服务器在调用socket函数创建套接字时,参数设置如下:

  • 协议家族选择AF_INET,因为我们要进行的是网络通信。
  • 创建套接字时所需的服务类型应该是SOCK_STREAM,因为我们编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。
  • 协议类型默认设置为0即可。

如果创建套接字后获得的文件描述符是小于0的,说明套接字创建失败,此时也就没必要进行后续操作了,直接终止程序即可。

cpp 复制代码
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" << _listensockfd; // 3
}

说明一下:

  • 实际TCP服务器创建套接字的做法与UDP服务器是一样的,只不过创建套接字时TCP需要的是流式服务,而UDP需要的是用户数据报服务。
  • 当析构服务器时,可以将服务器对应的文件描述符进行关闭。

服务端绑定

套接字创建完毕后我们实际只是在系统层面上打开了一个文件,该文件还没有与网络关联起来,因此创建完套接字后我们还需要调用bind函数进行绑定操作。

绑定的步骤如下:

  • 定义一个struct sockaddr_in结构体,将服务器网络相关的属性信息填充到该结构体当中,比如协议家族、IP地址、端口号等。
  • 填充服务器网络相关的属性信息时,协议家族对应就是AF_INET,端口号就是当前TCP服务器程序的端口号。在设置端口号时,需要调用htons函数将端口号由主机序列转为网络序列。
  • 在设置服务器的IP地址时,我们可以设置为本地环回127.0.0.1,表示本地通信。也可以设置为公网IP地址,表示网络通信。
  • 如果使用的是云服务器,那么在设置服务器的IP地址时,不需要显示绑定IP地址,直接将IP地址设置为INADDR_ANY即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY本质就是0,因此在设置时不需要进行网络字节序的转换。
  • 填充完服务器网络相关的属性信息后,需要调用bind函数进行绑定。绑定实际就是将文件与网络关联起来,如果绑定失败也没必要进行后续操作了,直接终止程序即可。

由于TCP服务器初始化时需要服务器的端口号,因此在服务器类当中需要引入端口号,当实例化服务器对象时就需要给传入一个端口号。而由于我当前使用的是云服务器,因此在绑定TCP服务器的IP地址时不需要绑定公网IP地址,直接绑定INADDR_ANY即可。

我们已经在udp中这样操作过一遍,实际上我们可以封装一个类来实现上述过程,并且还可以自由转换主机序列与网络序列

InetAddr类

cpp 复制代码
#pragma once
#include "Common.hpp"
// 网络地址和主机地址之间进行转换的类

class InetAddr
{
public:
    InetAddr(){}
    InetAddr(struct sockaddr_in &addr) : _addr(addr)
    {
        //网络转主机
        _port=ntohs(addr.sin_port);
        char ipbuffer[128];
        inet_ntop(AF_INET,&_addr.sin_addr,ipbuffer,sizeof(_addr));
        _ip=ipbuffer;
    }
    InetAddr(const std::string &ip, uint16_t port) : _ip(ip), _port(port)
    {
        //主机转网络
        memset(&_addr,0,sizeof(_addr));//清空结构体
        _addr.sin_family=AF_INET;
        inet_pton(AF_INET,_ip.c_str(),&_addr.sin_addr);
        _addr.sin_port=htons(_port);
        // local.sin_addr.s_addr = inet_addr(_ip.c_str()); 
    }
    InetAddr(uint16_t port) :_port(port),_ip()
    {
        // 主机转网络
        memset(&_addr, 0, sizeof(_addr));//清空结构体
        _addr.sin_family = AF_INET;
        _addr.sin_addr.s_addr = INADDR_ANY;
        _addr.sin_port = htons(_port);
    }

    uint16_t Port() { return _port; }
    std::string Ip() { return _ip; }
    const struct sockaddr_in &NetAddr() { return _addr; }

    const struct sockaddr *NetAddrPtr()
    {
        return CONV(_addr);
    }
    socklen_t NetAddrLen()
    {
        return sizeof(_addr);
    }
    bool operator==(const InetAddr &addr)
    {
        return addr._ip == _ip && addr._port == _port;
    }
    std::string StringAddr()
    {
        return _ip + ":" + std::to_string(_port);
    }
    ~InetAddr()
    {
    }
private:
    struct sockaddr_in _addr;
    std::string _ip;
    uint16_t _port;
};

我们在重载了两种主机转网络序列的函数,一个是指定IP另一个是任意IP,并且在三个构造函数中中我们没有使用在UDP中使用的int_addr()和int_ntoa()函数,具体原因详见补充说明。并且如果对sockaddr结构体不了解也可以去学习。

cpp 复制代码
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" << _listensockfd; // 3

    // 2. bind众所周知的端口号
    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: " << _listensockfd; // 3
}

服务端监听

UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。

因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。

listen函数

cpp 复制代码
int listen(int sockfd, int backlog);

参数说明:

  • sockfd:需要设置为监听状态的套接字对应的文件描述符。
  • backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。

返回值说明:

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

服务器监听

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

cpp 复制代码
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" << _listensockfd; // 3

    // 2. bind众所周知的端口号
    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: " << _listensockfd; // 3

    // 3. 设置socket状态为listen
    n = listen(_listensockfd, backlog);
    if (n < 0)
    {
        LOG(LogLevel::FATAL) << "listen error";
        exit(LISTEN_ERR);
    }
    LOG(LogLevel::INFO) << "listen success: " << _listensockfd; // 3
}

说明一下:

  • 初始化TCP服务器时创建的套接字并不是普通的套接字,而应该叫做监听套接字。
  • 在初始化TCP服务器时,只有创建套接字成功、绑定成功、监听成功,此时TCP服务器的初始化才算完成。

服务端接收连接测试

现在我们可以做一下简单的测试,看看当前服务器能否成功接收请求连接。在运行服务端时需要传入一个端口号作为服务端的端口号,然后我们用该端口号构造一个服务端对象,对服务端进行初始化后启动服务端即可。

在服务端我们还创建一个回调函数来处理回显的消息,这里注释掉

cpp 复制代码
#include "TcpServer.hpp"
#include "Common.hpp"


std::string defaulthandler(const std::string &word, InetAddr &addr)
{
    LOG(LogLevel::DEBUG) << "回调到了defaulthandler";
    std::string s = "haha, ";
    s += word;
    return s;
}
void Usage(const std::string proc)
{
    std::cerr << "Usage" << proc << "port" << std::endl;
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    uint16_t port = std::stoi(argv[1]);

    std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port,defaulthandler);
   
    tsvr->Init();
    tsvr->Run();
    return 0;
}

编译代码后,以./tcpserver 端口号的方式运行服务端。

服务端运行后,通过netstat命令可以查看到一个程序名为tcp_server的服务程序,它绑定的端口就是1024,而由于服务器绑定的是INADDR_ANY,因此该服务器的本地IP地址是0.0.0.0,这就意味着该TCP服务器可以读取本地任何一张网卡里面的数据。此外,最重要的是当前该服务器所处的状态是LISTEN状态,表明当前服务器可以接收外部的请求连接。

服务端处理请求

现在TCP服务器已经能够获取连接请求了,下面当然就是要对获取到的连接进行处理。但此时为客户端提供服务的不是监听套接字,因为监听套接字获取到一个连接后会继续获取下一个请求连接,为对应客户端提供服务的套接字实际是accept函数返回的套接字,下面就将其称为"sockfd"。

为了让通信双方都能看到对应的现象,我们这里就实现一个简单的回声TCP服务器,服务端在为客户端提供服务时就简单的将客户端发来的数据进行输出,并且将客户端发来的数据重新发回给客户端即可。当客户端拿到服务端的响应数据后再将该数据进行打印输出,此时就能确保服务端和客户端能够正常通信了

服务端处理请求

需要注意的是,服务端读取数据是从sockfd中读取的,而写入数据的时候也是写入进sockfd的。也就是说这里为客户端提供服务的套接字,既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。

在从服务套接字中读取客户端发来的数据时,如果调用read函数后得到的返回值为0,或者读取出错了,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符本质就是数组的下标,因此文件描述符的资源是有限的,如果我们一直占用,那么可用的文件描述符就会越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏。

cpp 复制代码
void Service(int sockfd, InetAddr &peer)
{
    char buffer[1024];
    while (true)
    {
        ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
        if (n > 0)
        {
            buffer[n] = 0;
            LOG(LogLevel::DEBUG) << peer.StringAddr() << "#" << buffer;
            std::string echo_string = _func(buffer, peer);//回调函数
            write(sockfd, echo_string.c_str(), echo_string.size());
        }
        else if (n == 0)
        {    //客户端退出了
            LOG(LogLevel::DEBUG) << peer.StringAddr() << "退出了";
            close(sockfd);
            break;
        }
        else
        {
            LOG(LogLevel::DEBUG) << peer.StringAddr() << "异常";
            close(sockfd);
            break;
        }
    }
}
void Run()
{
    _isrunning = true;
    while (_isrunning)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int sockfd = accept(_listensockfd, CONV(peer), &len);
        if (sockfd < 0)
        {
            LOG(LogLevel::WARNING) << "accept error";
            continue;
        }
        InetAddr addr(peer);
        LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();
    }
    _isrunning = false;
}

客户端创建套接字

客户端不需要进行绑定和监听:

  • 服务端要进行绑定是因为服务端的IP地址和端口号必须要众所周知,不能随意改变。而客户端虽然也需要IP地址和端口号,但是客户端并不需要我们进行绑定操作,客户端连接服务端时系统会自动指定一个端口号给客户端。
  • 服务端需要进行监听是因为服务端需要通过监听来获取新连接,但是不会有人主动连接客户端,因此客户端是不需要进行监听操作的。

此外,客户端必须要知道它要连接的服务端的IP地址和端口号,因此客户端除了要有自己的套接字之外,还需要知道服务端的IP地址和端口号,这样客户端才能够通过套接字向指定服务器进行通信。

客户端连接

需要注意的是,客户端不是不需要进行绑定,而是不需要我们自己进行绑定操作,当客户端向服务端发起连接请求时,系统会给客户端随机指定一个端口号进行绑定。因为通信双方都必须要有IP地址和端口号,否则无法唯一标识通信双方。也就是说,如果connect函数调用成功了,客户端本地会随机给该客户端绑定一个端口号发送给对端服务器。

此外,调用connect函数向服务端发起连接请求时,需要传入服务端对应的网络信息,否则connect函数也不知道该客户端到底是要向哪一个服务端发起连接请求。

cpp 复制代码
#include <iostream>
#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[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    std::string serverip = argv[1];
    int16_t serverport = std::stoi(argv[2]);
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    // 2. bind吗??需要。显式的bind?不需要!随机方式选择端口号
    // 2. 我应该做什么呢?listen?accept?都不需要!!
    // 2. 直接向目标服务器发起建立连接的请求
    InetAddr serveraddr(serverip, serverport);
    int n = connect(sockfd, serveraddr.NetAddrPtr(), serveraddr.NetAddrLen());
    if (n < 0)
    {
        std::cerr << "connect error" << std::endl;
        exit(CONNECT_ERR);
    }

    while (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 r=read(sockfd,buffer,sizeof(buffer)-1);
        if(r>0)
        {
            buffer[r]=0;
            std::cout << "server echo# " << buffer << std::endl;
        }
    }
    close(sockfd);
    return 0;
}

服务器测试

现在服务端和客户端均已编写完毕,下面我们进行测试

单执行流服务器的弊端

当我们仅用一个客户端连接服务端时,这一个客户端能够正常享受到服务端的服务。

但在这个客户端正在享受服务端的服务时,我们让另一个客户端也连接服务器,此时虽然在客户端显示连接是成功的,但这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端。

只有当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端

如何解决

单执行流的服务器一次只能给一个客户端提供服务,此时服务器的资源并没有得到充分利用,因此服务器一般是不会写成单执行流的。要解决这个问题就需要将服务器改为多执行流的,此时就要引入多进程或多线程。

多进程版的TCP网络程序

我们可以将当前的单执行流服务器改为多进程版的服务器。

当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。

由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字当中获取新连接,而不用关心获取上来的连接是否服务完毕。

cpp 复制代码
void Run()
{
    _isrunning = true;
    while (_isrunning)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int sockfd = accept(_listensockfd, CONV(peer), &len);
        if (sockfd < 0)
        {
            LOG(LogLevel::WARNING) << "accept error";
            continue;
        }
        InetAddr addr(peer);
        LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();

        // version1 --- 多进程版本
        pid_t id = fork(); // 父进程
        if (id < 0)
        {
            LOG(LogLevel::FATAL) << "fork error";
            exit(FORK_ERR);
        }
        else if (id == 0)
        {
            // 子进程
            close(_listensockfd);
            if (fork() > 0)
                exit(OK);
            // 孙子进程
            Service(sockfd, addr);
            exit(OK);
        }
        else
        {
            // 父进程
            close(sockfd);
            pid_t rid = waitpid(id, nullptr, 0); // 阻塞的吗?不会,因为子进程立马退出了
            (void)rid;
        }
    }
    _isrunning = false;
}

等待子进程问题

当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏。因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待。

阻塞式等待与非阻塞式等待:

  • 如果服务端采用阻塞的方式等待子进程,那么服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务。
  • 如果服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出。

总之,服务端要等待子进程退出,无论采用阻塞式等待还是非阻塞式等待,都不尽人意。此时我们可以考虑让服务端不等待子进程退出。

不等待子进程退出的方式

这里我们实现的是fork两次让孙子进程提供服务的方法

创建子进程处理客户端

复制代码
pid_t id = fork(); // 父进程创建子进程
  • 父进程
    • 继续循环调用 accept(),等待新的客户端连接。
    • 关闭 sockfd (父进程不需要与客户端通信)。
    • 调用 waitpid() 等待子进程退出,避免僵尸进程。
  • 子进程
    • 关闭 _listensockfd (子进程不需要监听新连接)。
    • 再次 fork() (孙子进程):
      • 子进程立即退出,避免僵尸进程。
      • 孙子进程 (真正的服务进程):
        • 调用 Service(sockfd, addr)处理客户端请求。
        • 处理完毕后,exit(OK)退出。

为什么使用两次 fork()?​

复制代码
if (fork() > 0) // 再次fork,子进程退出
    exit(OK);

第一次 fork()

  • 父进程继续监听新连接。
  • 子进程负责处理客户端请求。

第二次 fork()

  • 子进程(第一次 fork()的子进程)立即退出,避免僵尸进程。
  • 孙子进程 (第二次 fork()的子进程):
    • 成为 孤儿进程 (由 init进程接管)。
    • 处理完客户端请求后自动被系统回收,避免僵尸进程。

优点​:

  • 避免僵尸进程(父进程只需 waitpid()第一次 fork()的子进程)。
  • 减少父进程的负担(不需要管理所有子进程)。
相关推荐
BingoGo8 小时前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php
JaguarJack8 小时前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php·服务端
JaguarJack1 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
后端·php·服务端
BingoGo1 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
php
JaguarJack2 天前
告别 Laravel 缓慢的 Blade!Livewire Blaze 来了,为你的 Laravel 性能提速
后端·php·laravel
郑州光合科技余经理3 天前
代码展示:PHP搭建海外版外卖系统源码解析
java·开发语言·前端·后端·系统架构·uni-app·php
DianSan_ERP3 天前
电商API接口全链路监控:构建坚不可摧的线上运维防线
大数据·运维·网络·人工智能·git·servlet
呉師傅3 天前
火狐浏览器报错配置文件缺失如何解决#操作技巧#
运维·网络·windows·电脑
gihigo19983 天前
基于TCP协议实现视频采集与通信
网络协议·tcp/ip·音视频
QQ5110082853 天前
python+springboot+django/flask的校园资料分享系统
spring boot·python·django·flask·node.js·php