【计算机网络】socket编程 --- 实现简易TCP网络程序

在这里插入图片描述

👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍


一、 服务端

1.1 前言

这里我们规定将TCP服务器封装成一个类,以下是服务器程序框架

tcpServer.cc

#include <iostream>
#include "tcpServer.hpp"
#include <memory>
using namespace std;

int main()
{
    // 1. 创建TCP服务器端对象
    unique_ptr<tcpserver> tcpsvr(new tcpserver());
    // 2. 初始化TCP服务器
    tcpsvr.Init();
    // 3. 启动TCP服务器
    tcpsvr.Run();

    return 0;
}

tcpServer.hpp

#pragma once
#include <iostream>
#include "log.hpp"

log lg;

class tcpserver
{
public:
    tcpserver()
    {}

    ~tcpserver()
    {}

    void Init()
    {}

    void Run()
    {}

private:
};

说明:lg是我往期封装的日志类对象,这个在UDP也使用过。具体可以参考一下文章:

1.2 创建套接字

要想进行网络通信,第一步就是要先创建套接字socket

【函数原型】

#include <sys/types.h>           
#include <sys/socket.h>

int socket(int domain, int type, int protocol);

说明:

  • domain:指定套接字的协议族(如 AF_INET 表示IPv4AF_INET6表示IPv6网络、AF_UNIX / AF_LOCAL用于本地进程间通信)。
  • type:指定套接字的类型(如 SOCK_STREAM 表示TCPSOCK_DGRAM 表示UDP)。
  • protocol:指定具体的协议,通常设置为 0 表示使用默认协议。

综上,TCP服务器在创建套接字时,参数设置如下:

  • 参数一:因为我们要进行的是网络通信,协议家族选择AF_INET

  • 参数二:因为我们编写的是TCP服务器,所以选择SOCK_STREAM

  • 参数三:协议类型默认设置为0即可。

tcpServer.hpp

#pragma once

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <cstring>
#include "log.hpp"

log lg;

enum
{
    SOCK_ERR = 1,
};

class tcpserver
{
public:
    tcpserver()
        : _socketfd(-1)
    {}

    ~tcpserver()
    {
         if (_socketfd >= 0)
         {
             close(_socketfd);
         }
    }

    void Init()
    {
        // 创建套接字
        _socketfd = socket(AF_INET, SOCK_STREAM, 0);
        if (_socketfd == -1) // 套接字创建失败
        {
            lg.logmessage(Fatal, "create socket. errno: %d, describe: %s", errno, strerror(errno));
            exit(SOCK_ERR);
        }
        lg.logmessage(Info, "create socket. errno: %d, describe: %s", errno, strerror(errno));

    }

private:
    int _socketfd; // 套接字文件描述符

};

说明:

  • 实际TCP服务器创建套接字的做法与UDP服务器是一样的,只不过创建套接字时TCP需要的是流式服务,即SOCK_STREAM;而UDP需要的是用户数据报服务,即SOCK_DGRAM

  • 当析构服务器时,可以将服务器对应的文件描述符进行关闭。也可以选择不关闭。因为操作系统会在进程结束时自动回收文件描述符。然而,为了避免资源泄露和潜在的文件描述符耗尽问题,最好在程序退出前手动关闭文件描述符。这样可以更好地控制资源的释放。

1.3 绑定套接字

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

【函数原型】

#include <sys/types.h>         
#include <sys/socket.h>

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

说明:

  • sockfd:套接字的文件描述符,通常是由 socket 函数返回的。
  • addrsockaddr结构体是一个套接字的通用结构体,实际我们在进行网络通信时,还是要定义sockaddr_insockaddr_un这样的结构体,只不过在传参时需要将该结构体的地址类型进行强转为sockaddr*
    • sockaddr_in结构体:用于跨网络通信
    • sockaddr_un结构体:是用于本地通信
    • 注意:使用以上结构体需要加上头文件<netinet/in.h>
  • addrlenaddr 结构体的大小,以字节为单位。
  • 返回值:成功返回 0;失败返回 -1,并设置errno以指示错误原因。

在这里插入图片描述


首先对于bind函数,第一个参数和第三个参数没的说。主要是第二个参数。因为是跨网络通信,我们需要定义struct sockaddr_in结构体。而该结构体具体有哪些成员呢?我们可以在vscode中查看sockaddr_in结构体的相关成员:

在这里插入图片描述

注意:因为该结构体中还有其他字段,我们可以不用管。因此,可以使用 memset 函数可以确保结构体的所有字段都被初始化为零,从而避免意外数据。

#include <string.h>
void *memset(void *s, int c, size_t len);
// s是指向要填充的内存区域的指针
// c是要填充的值
// len是要填充的字节数

// -------------------------
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr)); // 将 addr 结构体的所有字节设置为零

这里再介绍一个函数bzero,它的作用是一个用于将内存区域的字节设置为零的函数。

#include <strings.h> 
void bzero(void *s, size_t len);

初始化之后,我们需要设置struct sockaddr_in成员变量,比如端口号和IP地址之类的

  • sin_family:表示协议家族。必须使用与socket创建时相同的协议家族。例如,如果你使用AF_INET 创建了套接字,bind时也应使用AF_INET
  • sin_port:表示端口号,是一个16位的整数。注意:端口号需要转换为网络字节序。因为只要进行网络通信,端口号一定是双方来回传输的数据,因此为了保证双方能够正常解析数据,需要将其转换为网络字节序。可以使用htons函数
#include <arpa/inet.h>  
 
uint16_t htons(uint16_t hostshort);
  • sin_addr:其中sin_addr的类型是struct in_addr,实际该结构体当中就只有一个成员,该成员就是一个32位的整数,就是IP地址。我们用户最直观的就是输入类似于192.168.1.1这种字符串形式的IP地址,可是这里sin_addr的类型是32位无符号整数,那么我们要把字符串转化为32位无符号整数。并且在网络通信中,IP地址和端口号一样,也是双方来回传输的数据,也要保证双方能够正常解析数据。由于这些操作在网络中经常会用到,我们并不需要自己手动实现一遍,系统已经我们提供了一些函数:

在这里插入图片描述

对于IP地址,我们可以先将其设置为本地环回127.0.0.1来进行本地通信测试;当然也可以设置为公网IP地址,表示网络通信。

但需要注意的是:

  • 如果你使用的是虚拟机,那么可以设置为公网IP地址;如果使用的是云服务器,那么在设置服务器的IP地址时,不需要显示绑定IP地址,但可以设置成本地环回地址,当然也可以直接设置为INADDR_ANY,这是个宏函数,本质上就是0.0.0.0。此时服务器就可以从本地任何一张网卡当中读取数据。
  • 另外,以上绑定只是在用户层上绑定了,填充完服务器网络相关的属性信息后,需要调用bind函数进行内核绑定。绑定实际就是将文件与网络关联起来,如果绑定失败也没必要进行后续操作了,直接终止程序即可。
#pragma once

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstring>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "log.hpp"

log lg;

enum
{
    SOCK_ERR = 1,
    BIND_ERR
};

class tcpserver
{
public:
    tcpserver(const uint16_t &port, const std::string &ip = "0.0.0.0")
        : _socketfd(-1), _port(port), _ip(ip)
    {}

    void Init()
    {
        // 1. 创建套接字
        _socketfd = socket(AF_INET, SOCK_STREAM, 0);
        if (_socketfd == -1) // 套接字创建失败
        {
            lg.logmessage(Fatal, "create socket. errno: %d, describe: %s", errno, strerror(errno));
            exit(SOCK_ERR);
        }
        lg.logmessage(Info, "create socket. errno: %d, describe: %s", errno, strerror(errno));

        // 2. 绑定套接字
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);  // 细节
        inet_aton(_ip.c_str(), &(local.sin_addr)); // 细节
        
        // 内核绑定
        int n = bind(_socketfd, (struct sockaddr *)&local, sizeof(local));
        if (n < 0) // 绑定失败
        {
            lg.logmessage(Fatal, "bind socket. errno: %d, describe: %s", errno, strerror(errno));
            exit(BIND_ERR);
        }
        lg.logmessage(Info, "bind socket. errno: %d, describe: %s", errno, strerror(errno));
    }

private:
    int _socketfd; // 套接字文件描述符
    uint16_t _port; // 服务器端口号
    std::string _ip; // 服务器IP地址
};

代码写到目前为止,虽然细节很多,但其实和UDP前半部分一样!都是套路!

而接下来就和UDP不一样了,因为TCP是一个面向连接的,所以在正式通信之前,一定要客户端和服务器连接上再说,即要将套接字设置为监听状态。因为只有设置为监听状态,才能知道有别人要来和我建立连接,然后基于连接再进行通信。

1.4 监听

listen 函数用于将套接字设置为监听状态,以等待传入的连接请求。它通常用于服务器端,配合 socketbind 函数使用

【函数原型】

#include <sys/types.h>         
#include <sys/socket.h>

int listen(int sockfd, int backlog);                                                                                                                                                                     

说明:

  • sockfd: 是之前通过 socket 函数创建的套接字描述符。
  • backlog: 指定套接字的待连接队列的最大长度(数目)。意思就是说,如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度。注意:一般不要设置太大,设置为510即可。
  • 返回值:成功返回0,失败返回-1,并设置 errno 以指示错误类型。
#pragma once

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstring>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "log.hpp"

log lg;

enum
{
    SOCK_ERR = 1,
    BIND_ERR,
    LISTEN_ERR
};

class tcpserver
{
public:
    void Init()
    {
        // 创建套接字
        _socketfd = socket(AF_INET, SOCK_STREAM, 0);
        if (_socketfd == -1) // 套接字创建失败
        {
            lg.logmessage(Fatal, "create socket. errno: %d, describe: %s", errno, strerror(errno));
            exit(SOCK_ERR);
        }
        lg.logmessage(Info, "create socket. errno: %d, describe: %s", errno, strerror(errno));

        // 绑定套接字
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);  // 细节          
        inet_aton(_ip.c_str(), &(local.sin_addr)); // 细节
        // 内核绑定
        int n = bind(_socketfd, (struct sockaddr *)&local, sizeof(local));
        if (n < 0) // 绑定失败
        {
            lg.logmessage(Fatal, "bind socket. errno: %d, describe: %s", errno, strerror(errno));
            exit(BIND_ERR);
        }
        lg.logmessage(Info, "bind socket. errno: %d, describe: %s", errno, strerror(errno));

        // 监听(一般配合socket和bind函数使用)
        int l = listen(_socketfd, 5);
        if (l == -1) // 监听失败
        {
            lg.logmessage(Fatal, "listen socket. errno: %d, describe: %s", errno, strerror(errno));
            exit(LISTEN_ERR);
        }
        lg.logmessage(Info, "listen socket. errno: %d, describe: %s", errno, strerror(errno));
    }

private:
    int _socketfd; // 套接字文件描述符
    uint16_t _port; // 服务器端口号
    std::string _ip; // 服务器IP地址
};

注意:如果TCP服务器无法成功监听(例如,通过调用listen函数时失败),这通常意味着服务器无法接受客户端发来的连接请求。处理监听失败的情况确实需要在程序中适当地终止或处理错误,以确保程序的稳定性和正确性。

至此, 初始化TCP服务器的Init函数已经写完了 ~

1.5 测试一下

写到这里我们可以简单的运行一下,至于服务器启动函数Run,我们可以简单的打印一些消息。

void Run()
{
   // 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环
   while (true)
   {
       lg.logmessage(Info, "tcp server is running...");
       sleep(1);
   }
}

现在我们可以做一下简单的测试,看看当前服务器能否成功接收请求连接。通过命令行参数让用户指定端口号,可以在程序中解析这些参数,然后用指定的端口号配置服务器;另外,IP地址就不需要用户指定传递了,因为IP地址一开始就已经绑定`INADDR_ANY

tcpServer.cc

#include <iostream>
#include "tcpServer.hpp"
#include <memory>
#include <string>
using namespace std;

// ./xxx 8888
void Usage(const string &proc)
{
    cout << "\n\tUsage: " << proc << " port(1024+)" << endl
         << endl;
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(1);
    }

    uint16_t port = stoi(argv[1]);

    // 1. 创建TCP服务器端对象
    unique_ptr<tcpserver> tcpsvr(new tcpserver(port));
    // 2. 初始化TCP服务器
    tcpsvr->Init();
    // 3. 启动TCP服务器
    tcpsvr->Run();

    return 0;
}

【运行结果】

在这里插入图片描述

另外,我们可以使用以下命令来查看服务器的运行状态

netstat -nltp

说明:

  • -n:显示数字地址,而不是解析成主机名。这意味着你将看到IP地址而不是主机名。
  • -l:仅显示正在监听的套接字。即只显示那些正在等待连接的端口。
  • -t:仅显示TCP连接。这样你就只会看到TCP协议的监听端口。
  • -p:显示与每个套接字相关联的进程PID 和程序名称。这样你可以知道哪个程序在监听哪个端口。

在这里插入图片描述

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

输出字段解释

  • Proto:协议类型(如 tcp)。
  • Recv-Q:接收队列中尚未处理的数据字节数。
  • Send-Q:发送队列中尚未确认的数据字节数。
  • Local Address:本地地址和端口。
  • Foreign Address:远程地址和端口(对监听套接字通常显示为 *)。
  • State:套接字的状态(如 LISTEN 表示监听中)。
  • PID/Program name:进程ID和程序名称。

1.6 接受连接

TCP服务器初始化后就可以开始运行了,但TCP服务器在与客户端进行网络通信之前,服务器需要先需要接受客户端的请求。

accept 函数用于从一个监听套接字(即已经调用了 listen 函数的套接字)接受一个新的连接请求。它将从等待连接的队列中取出一个连接请求,并返回一个新的套接字,用于与客户端进行数据交换。

【函数原型】

#include <sys/types.h>         
#include <sys/socket.h>

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

说明:

  • sockfd:是之前通过 socket 函数创建的套接字描述符,并且已调用 listen 函数设置为监听状态。
  • addr:指向 sockaddr 结构体的指针,用于存储客户端的地址信息。
  • addrlen:指向 socklen_t 类型的变量的指针,表示地址结构体的大小。函数返回时,它会包含实际填充的地址结构体的大小。
  • 返回值:成功返回新的套接字描述符,这个套接字用于与客户端进行通信;失败返回-1,并设置 errno 以指示错误类型。

socket函数和accept函数的返回值都是文件描述符,往后进行网络通信需要使用哪个文件描述符呢?

假设你和朋友出去吃饭,当你们走到一家名为“开心饭点”的餐馆时,门口有一个叫张三的人在热情地招呼你们进去。由于他的热情好客,你的朋友决定就去这家餐馆吃饭。当张三邀请你们进去时,他并没有一直陪着你们,而是去找了餐馆里的服务员来为你们服务,然后又回到门口继续招呼其他顾客。

在以上故事中,张三就像是socket函数返回的套接字,我们称为监听套接字,他负责接待和招呼客户(即等待连接请求)。当你们决定进入餐馆,张三相当于 accept 函数,它接受了连接请求,并将你们交给餐馆里的服务员(即新的套接字)来进行实际的服务,这个我们称为服务套接字。服务员负责处理你们的需求,就像 accept 函数返回的新套接字负责与客户端进行数据交换一样。

因此,往后进行通信需要使用 accept 返回的文件描述符,而socket 函数返回的文件描述符用于监听和接受连接。

注意:

  • accept 函数在某次调用中失败,服务器程序通常会继续运行,继续监听并接受新的连接请求。你可以把它比作张三虽然有时候吆喝失败,但他不会因为一次失败就放弃继续招呼其他客人。他会继续在门口招呼,直到成功为止。

  • socket网络编程接口默认是阻塞(等待),但你可以将它们设置为非阻塞模式以提高程序的效率和灵活性。你可以使用 fcntl() 函数来修改套接字的属性,将其设置为非阻塞模式(具体自己查)。

void Run()
{
    // 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环
    while (true)
    {
        // 接受连接
        struct sockaddr_in client;
        memset(&client, 0, sizeof(client));
        socklen_t len = sizeof(client);
        
        int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);
        if (acfd < 0) // 接收失败
        {
            lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));
            // 继续监听并接受新的连接请求
            continue;
        }
        lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));
    }
}

1.7 telnet命令

现在我们可以做一下简单的测试,看看当前服务器能否成功接收请求连接。虽然现在还没有编写客户端相关的代码,但是我们可以使用telnet命令。

telnet 是一个用于远程访问主机的网络协议和命令行工具。它允许你通过网络连接到远程服务器的特定端口,通常用于测试和诊断TCP/IP网络服务。telnet 底层使用TCP协议,因此可以用来检查服务器是否在特定端口上接受连接。

说明:如果没有telnet命令,可以执行以下命令

sudo yum install -y telnet

在这里插入图片描述

1.8 处理连接

现在TCP服务器已经能够成功接受连接请求了,下面当然就是要对获取到的连接请求进行处理。但需要注意的是:为客户端提供服务的不是监听套接字,监听套接字的任务只是获取到一个连接后会继续获取下一个请求连接;而为对应客户端提供服务的套接字实际是accept函数返回的套接字,也就是服务套接字。

又因为TCP是一个面向连接的协议,它提供了一个可靠的、字节流的传输方式。这意味着它处理的数据是连续的字节流。而readwrite函数可以直接操作这些字节流数据,提供流式读写操作,适合处理TCP数据传输中的流式数据。总之,因为TCP面向字节流,所以不用单独设计接口,直接使用writeread即可

read函数原型】

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

说明:

  • fd:特定的文件描述符,表示从该文件描述符中读取数据。
  • buf:指向用于存储读取数据的缓冲区的指针。
  • count:要读取的字节数。
  • 返回值说明:
    • 如果返回值大于0,该返回值则表示本次实际读取到的字节个数。
    • 如果返回值等于0,表示没有更多数据可读。
    • 如果返回值小于0,表示读取失败。此时,errno 会被设置为具体的错误代码,以说明错误原因。

write函数原型】

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

说明:

  • fd:文件描述符

  • buf:指向包含要写入数据的缓冲区的指针。

  • count:要写入的字节数。

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

为了让通信双方都能看到对应的现象,我们这里就实现一个简单的回响字符串的TCP服务器,当客户端发送消息给服务端,服务端打印客户端的消息后,再把其消息回显给客户端,此时就能确保服务端和客户端能够正常通信了。

// 回显
void v1(int acfd, const std::string &ipbuffer, const uint16_t &client_port)
{
    char buffer[4096];
    while (true)
    {
        ssize_t n = read(acfd, buffer, sizeof(buffer));
        if (n > 0) // 读取成功
        {
            buffer[n] = 0; // 当做字符串
            // 客户端接收打印
            cout << "client say# " << buffer << endl;
            // 回显给客户端
            std::string echo_string = "tcp-server echo# ";
            echo_string += buffer;
            write(acfd, echo_string.c_str(), echo_string.size());
        }
        else if (n == 0)
        {
            // 当客户端退出,意味着服务器没有任何数据可读,那么我们这里规定服务器也退出
            lg.logmessage(Info, "%s:%d quit, server close...", ipbuffer.c_str(), client_port);
            break;
        }
        else // n < 0
        {
            // 读取错误
            lg.logmessage(Warning, "server read error...");
            break;
        }
    }
}

void Run()
{
    // 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环
    while (true)
    {
        // 获取连接
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        memset(&client, 0, sizeof(client));
        int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);
        if (acfd < 0)
        {
            lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));
            continue;
        }
        lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));

        char ipbuffer[32];
        uint16_t client_port = ntohs(client.sin_port);
        inet_ntop(AF_INET, &(client.sin_addr), ipbuffer, sizeof(ipbuffer));

        v1(acfd, ipbuffer, client_port);
        close(acfd); // 关闭
    }
}

我们可以使用telnet命令来测试一下:

在这里插入图片描述

二、客户端

  • 客户端在调用socket函数创建套接字时,参数设置与服务端创建套接字时是一样的。

  • TCP客户端要绑定bind,但无需要显示编码进行绑定。一个一个来解释:

    • 首先客户端一定需要绑定,这是因为客户端向服务端发送请求,服务端收到请求后,要将结果回响给客户端,那么服务端要回响给唯一的那个客户端,客户端的端口号是什么并不重要。
    • 而不显示绑定是因为操作系统会在客户端建立连接时自动为其分配一个本地地址和端口。这是因为客户端的主要任务是连接到远程服务器,而不需要管理本地的网络接口。系统会处理这些细节,以确保客户端能够正常进行通信。在一些特殊情况下,如需要使用特定的本地端口或者地址时,客户端才需要调用 bind 函数。一般来说,默认行为就足够满足大多数应用的需求。
  • 客户端无需监听listen,服务端需要进行监听是因为服务端需要通过监听来获取新连接,但是不会有人主动连接客户端,因此客户端是不需要进行监听操作的。但客户端是要连接服务器的。

但客户端需要和TCP服务端建立连接,需要用到的是connect函数

【函数原型】

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

说明:

  • sockfd:一个有效的套接字描述符,该套接字通常通过 socket 函数创建。
  • addr:指向 sockaddr 结构体的指针,包含目标服务器的地址和端口信息。
  • addrlenaddr 结构体的大小(以字节为单位)。
  • 返回值:成功返回0,失败返回-1,并设置 errno 以指示错误类型。

注意:如果connect函数调用成功了,客户端本地会随机给该客户端绑定一个端口号发送给对端服务器。

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
#include <string>
using namespace std;

void Usage(const string &proc)
{
    cout << "\n\tUsage: " << proc << " 服务器ip 服务器port" << endl
         << endl;
}

int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }

    int socketfd = socket(AF_INET, SOCK_STREAM, 0);
    // 创建套接字
    if (socketfd < 0)
    {
        cerr << "socket error" << endl;
        return 1;
    }
    // 无需显示bind

    // 客户端中建立与服务器的连接
    string server_ip = argv[1];
    uint16_t server_port = stoi(argv[2]);

    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);
    inet_pton(AF_INET, server_ip.c_str(), &(server.sin_addr));

    int n = connect(socketfd, (struct sockaddr *)&server, sizeof(server));
    if (n < 0)
    {
        cerr << "connect error" << endl;
        return 2;
    }

    // 给服务器发消息
    string message;
    while (true)
    {
        cout << "Please Enter: ";
        getline(cin, message);
        write(socketfd, message.c_str(), message.size());

        char inbuffer[4096];
        int n = read(socketfd, inbuffer, sizeof(inbuffer));
        if (n > 0)
        {
            inbuffer[n] = 0;
            cout << inbuffer << endl;
        }
    }

    close(socketfd);

    return 0;
}

【运行结果】

在这里插入图片描述

三、改进TCP网络程序

3.1 单执行流服务器的弊端

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

在这里插入图片描述

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

在这里插入图片描述

通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。这正是因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务。当服务端调用accept函数获取到连接后就只给第一次还没退出的客户端提供服务,但在服务端可以为多个客户端提供服务。

那么客户端为什么会显示连接成功?

当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上来罢了。

实际在底层会为我们维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列当中,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。

如何解决?

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

3.2 多进程版

服务器既想要为当前客户端进行服务,也想要在服务期间继续处理其他的新连接。因此,当服务端调用accept函数获取到新连接后,不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。

需要注意的是,文件描述符表是隶属于一个进程的,子进程创建后会继承父进程的文件描述符表。比如父进程打开了一个文件,该文件对应的文件描述符是3,此时子进程的3号文件描述符也会指向这个打开的文件。所以,我们针对父子进程来关闭一些不需要的文件描述符。父进程关系的是连接状态,也就是监听套接字,那么可以将服务套接字给关闭;同理,子进程关系的是服务套接字,因此可以将监听套接字给关闭掉。

void Run()
{
    // 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环
    while (true)
    {
        // 获取连接
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        memset(&client, 0, sizeof(client));
        int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);
        if (acfd < 0)
        {
            lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));
            continue;
        }
        lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));

        char ipbuffer[32];
        uint16_t client_port = ntohs(client.sin_port);
        inet_ntop(AF_INET, &(client.sin_addr), ipbuffer, sizeof(ipbuffer));

        // v1 - 单进程版
        // v1(acfd, ipbuffer, client_port);
        // close(acfd); // 关闭

        // v2 - 多进程版
        pid_t pid = fork();
        if (pid == 0) // child
        {
            // 子进程提供服务
            close(_socketfd);
            v1(acfd, ipbuffer, client_port);
            close(acfd); // 关闭
            exit(0);
        }
        else if (pid > 0) // parent
        {
            // 父进程继续获取新连接
            close(acfd);
            pid_t rid = waitpid(pid, nullptr, 0); // 父进程回收子进程
            (void)rid;
        }
        else // 子进程创建失败
        {
            lg.logmessage(Fatal, "fork child. errno: %d, describe: %s", errno, strerror(errno));
            exit(FORK_ERR);
        }
    }
}

以上代码还有一个问题,当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏。而我们设置的是阻塞等待,那么现在就很矛盾了,服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务。

那么有的人说,可以设置非阻塞轮询等待WNOHANG。虽然在子进程为客户端提供服务期间,父进程可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测询问子进程是否退出。因此,代码还是不够完美。

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

  • 方法一:子进程创建进程(孙子进程),如果孙子进程创建成功,就让子进程退出,最后让孙子进程为客户端提供服务。这样的话,父进程阻塞等待就可以不用等待子进程服务完当前客户端,而是立马回收,进而可以继续获取新连接accept

注意:不需要等待孙子进程退出。由于子进程创建完孙子进程后就立刻退出了,那么孙子进程就变成了孤儿进程,而孤儿进程会被系统领养。👉 点击回炉重造

void Run()
{
    // 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环
    while (true)
    {
        // 获取连接
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        memset(&client, 0, sizeof(client));
        int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);
        if (acfd < 0)
        {
            lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));
            continue;
        }
        lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));

        char ipbuffer[32];
        uint16_t client_port = ntohs(client.sin_port);
        inet_ntop(AF_INET, &(client.sin_addr), ipbuffer, sizeof(ipbuffer));

        // v1 - 单进程版
        // v1(acfd, ipbuffer, client_port);
        // close(acfd); // 关闭

        // v2 - 多进程版
        pid_t pid = fork();
        if (pid == 0) // child
        {
            // 子进程提供服务
            close(_socketfd);
            // 子进程再创建孙子进程
            if (fork() > 0)
            {
                exit(0);
            }   
            v1(acfd, ipbuffer, client_port);
            close(acfd); // 关闭
            exit(0);
        }
        else if (pid > 0) // parent
        {
            // 父进程继续获取新连接
            close(acfd);
			// 父进程回收子进程
            pid_t rid = waitpid(pid, nullptr, 0); 
            (void)rid;
        }
        else // 子进程创建失败
        {
            lg.logmessage(Fatal, "fork child. errno: %d, describe: %s", errno, strerror(errno));
            exit(FORK_ERR);
        }
    }
}

【运行结果】

在这里插入图片描述

  • 方法二:捕捉SIGCHLD信号,将其处理动作设置为忽略。实际当子进程退出时会给父进程发送SIGCHLD信号,如果父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程就只需专心处理自己的工作,不必关心子进程了。
void Run()
{
    // 方法二:忽略SIGCHLD信号
    signal(SIGCHLD, SIG_IGN);

    // 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环
    while (true)
    {
        // 获取连接
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        memset(&client, 0, sizeof(client));
        int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);
        if (acfd < 0)
        {
            lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));
            continue;
        }
        lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));

        char ipbuffer[32];
        uint16_t client_port = ntohs(client.sin_port);
        inet_ntop(AF_INET, &(client.sin_addr), ipbuffer, sizeof(ipbuffer));

        // v1 - 单进程版
        // v1(acfd, ipbuffer, client_port);
        // close(acfd); // 关闭

        // v2 - 多进程版
        pid_t pid = fork();
        if (pid == 0) // child
        {
            // 子进程提供服务
            close(_socketfd);
            // 方法一: 子进程再创建孙子进程
            // if (fork() > 0)
            // {
            //     exit(0);
            // }
            v1(acfd, ipbuffer, client_port);
            close(acfd); // 关闭
            exit(0);
        }
        else if (pid > 0) // parent
        {
            // 父进程继续获取新连接
            close(acfd);

            pid_t rid = waitpid(pid, nullptr, 0); // 父进程回收子进程
            (void)rid;
        }
        else // 子进程创建失败
        {
            lg.logmessage(Fatal, "fork child. errno: %d, describe: %s", errno, strerror(errno));
            exit(FORK_ERR);
        }
    }
}

【运行结果】

在这里插入图片描述

3.3 多线程版

创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块、进程地址空间、页表等数据结构。然而,创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源。因此,在实现多执行流的服务器时最好采用多线程进行实现

当主线程调用accept函数获取到一个新连接后,就可以创建一个新线程,让新线程为对应客户端提供服务。

当然,主线程创建出新线程后,也是需要等待新线程退出的,否则也会造成类似于僵尸进程这样的问题。但对于线程来说,如果不想让主线程等待新线程退出,可以让创建出来的新线程调用pthread_detach函数进行线程分离,当这个线程退出时系统会自动回收该线程所对应的资源。此时,主线程就可以继续调用accept函数获取新连接,而让新线程去服务对应的客户端。

另外,主线程创建出来的新线程依旧属于这个进程,因此,创建新线程时并不会为该线程创建独立的文件描述符表,所有的线程看到的都是同一张文件描述符表。

在这里插入图片描述

因此,当主线程调用accept函数获取到一个文件描述符后,其他创建的新线程是能够直接访问这个文件描述符的。但注意了,虽然新线程能够直接访问主线程accept上来的文件描述符,但此时新线程并不知道它所服务的客户端对应的是哪一个文件描述符。因此,主线程创建新线程后需要告诉新线程对应应该访问的文件描述符,也就是告诉每个新线程在服务客户端时,应该对哪一个套接字进行操作。

class tcpserver; // 声明

class ThreadData
{
public:
    ThreadData(int fd, const std::string &ip, const uint16_t &port, tcpserver *t)
        : socketfd(fd), client_ip(ip), client_port(port), ts(t)
    {
    }

    int socketfd;
    std::string client_ip;
    uint16_t client_port;
    tcpserver *ts;
};

class tcpserver
{
public:
	static void *Routine(void *args)
    {
        pthread_detach(pthread_self());
        ThreadData *td = static_cast<ThreadData *>(args);
        // 新线程提供服务
        td->ts->v1(td->socketfd, td->client_ip, td->client_port);
        delete td;
        return nullptr;
    }

    void Run()
    {
        // 方法二:忽略SIGCHLD信号
        signal(SIGCHLD, SIG_IGN);

        // 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环
        while (true)
        {
            // 获取连接
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            memset(&client, 0, sizeof(client));
            int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);
            if (acfd < 0)
            {
                lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));
                continue;
            }
            lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));

            char ipbuffer[32];
            uint16_t client_port = ntohs(client.sin_port);
            inet_ntop(AF_INET, &(client.sin_addr), ipbuffer, sizeof(ipbuffer));

            // v1 - 单进程版
            
            // v2 - 多进程版
            
            // v3 - 多线程版
            pthread_t tid;
            ThreadData *td = new ThreadData(acfd, ipbuffer, client_port, this);
            pthread_create(&tid, nullptr, Routine, (void *)td);
        }
    }
};

【运行结果】

在这里插入图片描述

3.4 线程池版

以上多线程版其实还存在一些问题:

  • 每当有新连接到来时,服务端的主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁。这样做效率低下(频繁调用系统调用接口)。
  • 如果有大量的客户端连接请求,此时服务端要为每一个客户端创建对应的服务线程。计算机当中的线程越多,CPU的压力就越大,因为CPU要不断在这些线程之间来回切换,此时CPU在调度线程的时候,线程和线程之间切换的成本就会变得很高。此外,一旦线程太多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也迟迟得不到应答。

综上,可以预先创建一组线程来处理所有客户端请求,避免了频繁的线程创建和销毁。这样可以减少线程切换的开销,并提高响应速度。这就不是线程池嘛!!!

这里可以引用往期写过的单例版线程池。在线程池里面有一个任务队列,在线程池当中我们默认创建了5个线程,当有新的任务到来的时候,就可以将任务push到线程池当中,这些线程不断检测任务队列当中是否有任务,如果有任务就拿出任务,然后调用该任务对应的run函数对该任务进行处理,如果线程池当中没有任务那么当前线程就会进入休眠状态。

这实际也是一个生产者消费者模型,其中服务进程就作为了任务的生产者,而后端线程池当中的若干线程就不断从任务队列当中获取任务进行处理,它们承担的就是消费者的角色,其中生产者和消费者的交易场所就是线程池当中的任务队列。

ThreadPool.hpp

#pragma once

#include <iostream>
#include <pthread.h>
#include <vector>
#include <string>
#include <queue>
#include <unistd.h>

#define defaultNum 5 // 线程池默认的线程个数

struct threadInfo
{
    pthread_t tid;
    std::string threadname;
};

template <class T>
class ThreadPool
{
private:
    // 默认构造函数
    ThreadPool(int num = defaultNum) // 默认在线程池创建5个线程
        : _threads(num)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_cond, nullptr);
    }

    // 析构函数
    ~ThreadPool()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_cond);
    }

public:
    // 线程池中线程的执行例程
    static void *HandlerTask(void *args)
    {
        // 线程分离
        pthread_detach(pthread_self());
        ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
        std::string name = tp->GetThreadName(pthread_self());
        // 不断从任务队列获取任务进行处理
        while (true)
        {
            // 线程先检测任务队列有无任务
            // 而任务队列是临界资源,那么需要加锁
            pthread_mutex_lock(&(tp->_mutex));
            while ((tp->_tasks).empty())
            {
                // 如果列表为空
                // 线程直接去休眠, 即去条件变量的线程等待列表等待
                pthread_cond_wait(&(tp->_cond), &(tp->_mutex));
            }
            // 如果有任务,则获取任务
            T t = tp->pop();

            pthread_mutex_unlock(&(tp->_mutex));
            // 处理任务
            t.run();
        }
    }

    // 启动线程(常见线程)
    void start()
    {
        for (int i = 0; i < _threads.size(); i++)
        {
            _threads[i].threadname = "thread-" + std::to_string(i + 1);
            pthread_create(&(_threads[i].tid), nullptr, HandlerTask, this); // 注意参数传入this指针
        }
    }

    // 向任务列表中塞任务 -- 主线程调用
    void push(const T &t)
    {
        pthread_mutex_lock(&_mutex);
        // 向任务队列里塞任务
        _tasks.push(t);
        // queue容器会自动扩容,不需要特判任务列表的容量是否够
        // 接下来唤醒线程池中的线程
        pthread_mutex_unlock(&_mutex);
        pthread_cond_signal(&_cond);
    }

    // 去掉任务队列中的任务
    T pop()
    {
        // 这个函数不需要对临界资源加锁
        // 因为pop函数只在HandlerTask函数中被调用
        // 而在HandlerTask函数中已经对该函数加锁了

        T t = (_tasks).front();
        _tasks.pop();
        return t;
    }

    std::string GetThreadName(pthread_t tid)
    {
        for (const auto &e : _threads)
        {
            if (e.tid == tid)
            {
                return e.threadname;
            }
        }
        return "None";
    }

    static ThreadPool<T> *getInstance(int num = defaultNum)
    {
        if (_tp == nullptr)
        {
            pthread_mutex_lock(&_mtx);
            if (_tp == nullptr)
            {
                _tp = new ThreadPool<T>(num);
            }
            pthread_mutex_unlock(&_mtx);
        }
        return _tp;
    }

private:
    std::vector<threadInfo> _threads; // 将线程维护在数组中
                 
    std::queue<T> _tasks; // 任务队列

    pthread_mutex_t _mutex;
    pthread_cond_t _cond;

    static ThreadPool<T> *_tp;
    static pthread_mutex_t _mtx;
};

template <class T>
ThreadPool<T> *ThreadPool<T>::_tp = nullptr;

template <class T>
pthread_mutex_t ThreadPool<T>::_mtx = PTHREAD_MUTEX_INITIALIZER;

tcpServer.hpp

 void Run()
{
    // 启动线程池
    ThreadPool<Task>::getInstance()->start();

    // 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环
    while (true)
    {
        // 获取连接
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        memset(&client, 0, sizeof(client));
        int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);
        if (acfd < 0)
        {
            lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));
            continue;
        }
        lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));

        char ipbuffer[32];
        uint16_t client_port = ntohs(client.sin_port);
        inet_ntop(AF_INET, &(client.sin_addr), ipbuffer, sizeof(ipbuffer));

        // v1 - 单进程版

        // v2 - 多进程版

        // v3 - 多线程版
 
        // v4 - 线程池版
        Task t(acfd, ipbuffer, client_port);      // 构建任务对象
        ThreadPool<Task>::getInstance()->push(t); // 发布任务给线程池
    }
}

任务类的设计Task.hpp

直接将v1函数复制过来即可

#pragma once
#include <iostream>
#include <string>
#include "log.hpp"

using std::cout;
using std::endl;

extern log lg;

class Task
{
public:
    Task(int acfd, const std::string &ipbuffer, const uint16_t &client_port)
        : _acfd(acfd), _client_ip(ipbuffer), _client_port(client_port)
    {
    }

    ~Task()
    {
    }

    void run()
    {
        char buffer[4096];
        while (true)
        {
            ssize_t n = read(_acfd, buffer, sizeof(buffer));
            if (n > 0) // 读取成功
            {
                buffer[n] = 0; // 当做字符串
                // 客户端接收打印
                cout << "client say# " << buffer << endl;
                // 回显给客户端
                std::string echo_string = "tcp-server echo# ";
                echo_string += buffer;
                write(_acfd, echo_string.c_str(), echo_string.size());
            }
            else if (n == 0)
            {
                // 当客户端退出,意味着服务器没有任何数据可读,那么我们这里规定服务器也退出
                lg.logmessage(Info, "%s:%d quit, server close...", _client_ip.c_str(), _client_port);
                break;
            }
            else // n < 0
            {
                // 读取错误
                lg.logmessage(Warning, "server read error...");
                break;
            }
        }
    }

private:
    int _acfd;
    std::string _client_ip;
    uint16_t _client_port;
};

【运行结果】

在这里插入图片描述

四、相关代码

我的Gitee链接:👉 点击跳转

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值