【Linux网络编程】Socket - TCP

目录

[V1版本 - Echo Server](#V1版本 - Echo Server)

初始化服务器

启动服务器

客户端

一些BUG与问题

解决服务器无法一次处理多个请求的问题

多进程版本

多线程版本

线程池版本

[V2版本 - 多线程远程执行命令](#V2版本 - 多线程远程执行命令)


V1版本 - Echo Server

初始化服务器

TCP大部分内容与UDP是相同的,我们直接写代码

cpp 复制代码
static const uint16_t gport = 8080;

class TcpServer
{
public:
    TcpServer(int port = gport):_port(port)
    {}
    void InitServer()
    {
        // 1. 创建TCP套接字
        _sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
        if(_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "socket error";
            Die(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "socket create success, sockfd is: " << _sockfd;

        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = INADDR_ANY;

        // 2. 绑定
        int n = ::bind(_sockfd, CONV(&local), sizeof(local));
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            Die(BIND_ERR);
        }
        LOG(LogLevel::INFO) << "bind success, sockfd is: " << _sockfd;
    }
    void Start()
    {

    }
    ~TcpServer()
    {}
private:
    int _sockfd;
    uint16_t _port;
};

到这,除了创建套接字时传入的参数有区别之外,其他都是与UDP一样的。之前说过,UDP是无连接的,客户端创建完套接字之后,直接就可以向服务端发送消息,没有连接;TCP是有连接的,客户端要向服务端发送消息时,需要先建立连接,当连接建立成功时,才可以发送,也就是说,服务端,随时随地等待被连接

举一个例子帮助理解。就像我们去餐厅吃饭,不是一进去就直接吃,而是要和老板交代要吃什么,付完钱后等上菜才可以吃,与老板沟通的过程叫做建立连接,通过握手来建立连接,是协商的过程。并且任何正常餐厅,老板或服务员一定是在店里等待着客人去吃饭。所以,TCP在套接字绑定完成之后,需要将套接字设置为监听状态,监听状态就是随时等待别人来连接我

cpp 复制代码
#include <sys/socket.h>

int listen(int sockfd, int backlog);

通过这个系统调用,将指定的套接字设置为监听状态。第二个参数先不管,不要传入0或太大的数即可。成功返回0,失败返回-1,并设置errno。假设餐厅人很多,吃饭需要排队,老板肯定不会要求排队的人太多,因为都在店里必然会占一些空间,这个backlog就是限制排队的人数的。具体是什么后面会说。

cpp 复制代码
#define BACKLOG 8

void InitServer()
{
    // 1. 创建TCP套接字
    _sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
    if (_sockfd < 0)
    {
        LOG(LogLevel::FATAL) << "socket error";
        Die(SOCKET_ERR);
    }
    LOG(LogLevel::INFO) << "socket create success, sockfd is: " << _sockfd;

    struct sockaddr_in local;
    memset(&local, 0, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(_port);
    local.sin_addr.s_addr = INADDR_ANY;

    // 2. 绑定
    int n = ::bind(_sockfd, CONV(&local), sizeof(local));
    if (n < 0)
    {
        LOG(LogLevel::FATAL) << "bind error";
        Die(BIND_ERR);
    }
    LOG(LogLevel::INFO) << "bind success, sockfd is: " << _sockfd;

    // 3. 将套戒指设置为监听状态
    n = ::listen(_sockfd, BACKLOG);
    if (n < 0)
    {
        LOG(LogLevel::FATAL) << "listen errno";
        Die(LISTEN_ERR);
    }
    LOG(LogLevel::INFO) << "listen success, sockfd is: " << _sockfd;
}

注意:这里不会阻塞在listen处,listen只是设置了套接字的状态。

启动服务器

cpp 复制代码
void Start()
{
    _isrunning = true;
    while (_isrunning)
    {

    }
}
void Stop()
{
    _isrunning = false;
}
cpp 复制代码
#include <sys/socket.h>

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

当服务器的套接字处于监听状态时,是不能直接获取消息的,而是需要先获取新连接。此时使用系统调用accept,表示从指定的文件描述符中获取新连接,这个套接字就是服务端的套接字,第二、三个参数是获取谁来连接服务器的,是两个输出型参数。没人连接时,就会阻塞在accept处。返回值:若调用成功,返回一个文件描述符,调用失败,返回-1,并设置errno。

返回的这一个文件描述符是什么呢?我们举一个例子帮助理解。在一些景区饭店,为了增大客流量,可能会专门让一个人在街上拉客,拉客的人拉到客人后,并不会进入饭店,只是将客人带到饭店处,叫来一个服务员招呼客人,客人进店吃饭后,拉客的人又继续回去拉客了。当又拉到客人,带到饭店门口,又会叫来另一名服务员招呼客人。这个饭店就是一个服务器,我们今天只看一个服务器即可。饭店里一个一个的服务员就是一个一个的文件描述符,因为只有通过文件描述符才能提供服务,拉客的人也是文件描述符,只是他不提供服务,只负责拉客。拉客的人就是accept的第一个参数,店里服务员就是accept的返回值。也就是说,accept的第一个参数的文件描述符专门用来获取新链接,未来为客户端提供服务的由accept的返回值决定。修改一下成员变量_sockfd的名称

cpp 复制代码
#define BACKLOG 8

static const uint16_t gport = 8080;

class TcpServer
{
public:
    TcpServer(int port = gport):_port(port), _isrunning(false)
    {}
    void InitServer()
    {
        // 1. 创建TCP套接字
        _listensockfd = ::socket(AF_INET, SOCK_STREAM, 0);
        if(_listensockfd < 0)
        {
            LOG(LogLevel::FATAL) << "socket error";
            Die(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "socket create success, sockfd is: " << _listensockfd;

        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = INADDR_ANY;

        // 2. 绑定
        int n = ::bind(_listensockfd, CONV(&local), sizeof(local));
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            Die(BIND_ERR);
        }
        LOG(LogLevel::INFO) << "bind success, sockfd is: " << _listensockfd;

        // 3. 将套戒指设置为监听状态
        n = ::listen(_listensockfd, BACKLOG);
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "listen errno";
            Die(LISTEN_ERR);
        }
        LOG(LogLevel::INFO) << "listen success, sockfd is: " << _listensockfd;
    }
    void Start()
    {
        _isrunning = true;
        while(_isrunning)
        {
            // 1. 获取新连接
            struct sockaddr_in peer;
            socklen_t peerlen = sizeof(peer);
            LOG(LogLevel::DEBUG) << "accept ing ...";
            int sockfd = ::accept(_listensockfd, CONV(&peer), &peerlen);
            if(sockfd < 0)
            {
                LOG(LogLevel::WARNING) << "accept error: " << strerror(errno);
                continue;
            }
            // 获取连接成功
            LOG(LogLevel::INFO) << "accept success, sockfd is: " << sockfd;
        }
    }
    void Stop()
    {
        _isrunning = false;
    }
    ~TcpServer()
    {}
private:
    int _listensockfd; // 监听套接字
    uint16_t _port;
    bool _isrunning;
};

我们可以来验证一下是否能与我们的服务器建立连接。


当我们将服务器运行起来之后,此时就是在Linux系统上启动了一个TCP服务。netstat的-l选项是查看处于监听状态下的服务,我们的服务器此时就处于监听状态下。我们现在没有写客户端,要怎么连接服务端呢?现在,有非常多的应用底层使用的就是TCP。当我们打开浏览器,访问某个网站时,用的是HTTP或HTTPS协议,但是底层仍然使用的是TCP协议。云服务器也是在网络上公开的一个服务,因为是有公网IP的。而浏览器在访问某个网站时,底层协议就是TCP。所以,我们可以通过浏览器去访问我们的服务器。

可以看到,我们的服务端已经能够被连接了。

当服务端获取连接成功之后,就可以与客户端通信了。我们定义一个函数来处理请求。因为TCP是面向字节流的,所以可以使用文件接口来对网络进行读和写。另外,TCP是全双工的,所以读和写使用的是同一个文件描述符。

cpp 复制代码
void HandlerRequest(int sockfd)
{
    LOG(LogLevel::INFO) << "HandlerRequest, sockfd is: " << sockfd;
    char inbuffer[4096];
    while (true)
    {
        ssize_t n = ::read(sockfd, inbuffer, sizeof(inbuffer) - 1);
        if (n > 0)
        {
            LOG(LogLevel::INFO) << inbuffer;

            inbuffer[n] = '\0';
            std::string echo_str = "server echo# ";
            echo_str += inbuffer;

            ::write(sockfd, echo_str.c_str(), echo_str.size());
        }
    }
}
void Start()
{
    _isrunning = true;
    while (_isrunning)
    {
        // 1. 获取新连接
        struct sockaddr_in peer;
        socklen_t peerlen = sizeof(peer);
        int sockfd = ::accept(_listensockfd, CONV(&peer), &peerlen);
        if (sockfd < 0)
        {
            LOG(LogLevel::WARNING) << "accept error: " << strerror(errno);
            continue;
        }
        // 获取连接成功
        LOG(LogLevel::INFO) << "accept success, sockfd is: " << sockfd;

        // 处理请求
        HandlerRequest(sockfd);
    }
}

来验证一下能否向服务器发送请求,并得到回复的消息。我们现在并没有客户端,Linux下可以通过指令telnet去访问某一个服务器。


可以看到,此时服务器是可以正常工作的。连接成功,也能拿到回显。

客户端

cpp 复制代码
// ./client_tcp server_ip server_port
int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        std::cout << "Usage:./client_tcp server_ip server_port" << std::endl;
        return 1;
    }
    std::string server_ip = argv[1];
    int server_port = std::stoi(argv[2]);
    int sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
    if(sockfd < 0)
    {
        std::cout << "create socket failed" << std::endl;
        return 2;
    }

    ::close(sockfd);
    return 0;
}

客户端仍然是不需要显示绑定的。客户端创建完套接字之后,是不能直接向服务端发送消息的,需要先与服务端建立连接才可以向服务端发送消息。

cpp 复制代码
#include <sys/socket.h>

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

第一个参数传入客户端的套接字,第二、三个参数的服务端的IP地址和端口号。成功返回0,失败返回-1,并设置errno。客户端在首次与服务器建立连接时,就会自动绑定。

cpp 复制代码
// ./client_tcp server_ip server_port
int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        std::cout << "Usage:./client_tcp server_ip server_port" << std::endl;
        return 1;
    }
    std::string server_ip = argv[1];
    int server_port = std::stoi(argv[2]);
    int sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
    if(sockfd < 0)
    {
        std::cout << "create socket failed" << std::endl;
        return 2;
    }

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

    // 与服务端建立连接
    int n = ::connect(sockfd, CONV(&server_addr), sizeof(server_addr));
    if(n < 0)
    {
        std::cout << "connect failed" << std::endl;
        return 3;
    }

    // 与服务端通信
    std::string message;
    while(true)
    {
        char inbuffer[1024];
        std::cout << "input message: ";
        std::getline(std::cin, message);
        
        n = ::write(sockfd, message.c_str(), message.size());
        if(n > 0)
        {
            int m = ::read(sockfd, inbuffer, sizeof(inbuffer));
            if(m > 0)
            {
                inbuffer[m] = '\0';
                std::cout << inbuffer << std::endl;
            }
            else break;
        }
        else break;
    }

    ::close(sockfd);
    return 0;
}

一些BUG与问题

1. 无法重新获取连接

我们现在来试一试让客户端与服务端进行通信。


会发现,当我们将客户端退出,再重新启动客户端,此时向服务端发送消息服务端已经接收不到了。问题出在HandlerRequest,我们只做了read读取成功的判断。HandlerRequest永远不会退出,导致客户端会一直与服务端连接,服务端无法重新获取连接。

cpp 复制代码
void HandlerRequest(int sockfd)
{
    LOG(LogLevel::INFO) << "HandlerRequest, sockfd is: " << sockfd;
    char inbuffer[4096];
    while (true)
    {
        ssize_t n = ::read(sockfd, inbuffer, sizeof(inbuffer) - 1);
        if (n > 0)
        {
            LOG(LogLevel::INFO) << inbuffer;

            inbuffer[n] = '\0';
            std::string echo_str = "server echo# ";
            echo_str += inbuffer;

            ::write(sockfd, echo_str.c_str(), echo_str.size());
        }
        else if (n == 0)
        {
            // 客户端已经退出了,应该退出处理逻辑,重新获取连接
            LOG(LogLevel::INFO) << "client quit: " << sockfd;
            break;
        }
        else
        {
            // 读取失败
            break;
        }
    }
}


此时就可以正常通信了。

2. 文件描述符泄漏问题

会发现,每次获取新连接时,客户端的文件描述符都是会变化的。这里客户端的文件描述符就是指accept的返回值。

我们知道文件描述符是文件描述符表的下标,是有限的。所以,文件描述符是有用的、有限的 ,那么他就是一个资源。我们知道,文件的生命周期是随进程的,而服务器进程是永远不退出的,就会导致文件描述符泄漏问题。所以,当客户端退出时,要将这个文件描述符关闭。

cpp 复制代码
void HandlerRequest(int sockfd)
{
    LOG(LogLevel::INFO) << "HandlerRequest, sockfd is: " << sockfd;
    char inbuffer[4096];
    while (true)
    {
        ssize_t n = ::read(sockfd, inbuffer, sizeof(inbuffer) - 1);
        if (n > 0)
        {
            LOG(LogLevel::INFO) << inbuffer;

            inbuffer[n] = '\0';
            std::string echo_str = "server echo# ";
            echo_str += inbuffer;

            ::write(sockfd, echo_str.c_str(), echo_str.size());
        }
        else if (n == 0)
        {
            // 客户端已经退出了,应该退出处理逻辑,重新获取连接
            LOG(LogLevel::INFO) << "client quit: " << sockfd;
            break;
        }
        else
        {
            // 读取失败
            break;
        }
    }
    ::close(sockfd);
}

Linux下,进程的文件描述符表一般为64,难道一个服务器只能有几十个连接吗?并不是,文件描述符表是可以进行动态扩展的。云服务器的OS在上线之前已经被相应公司的工程师编译好了,一般是65535或10万。

3. UDP与TCP获取客户端信息的区别

在UDP中,接收客户端发来的消息可以使用recvfrom,recvfrom除了可以获取客户端发来的消息,还可以获取客户端的套接字信息。这里使用read要怎么获取客户端的套接字信息呢?

TCP获取客户端的套接字信息不在read,而是在accept获取。所以,获取客户端的信息:

  • 数据:客户端的文件描述符
  • 套接字信息:accept / recvfrom

获取客户端的套接字信息是有用的,因为服务端可能需要对客户端进行管理,像之前的聊天室。我们使用之前的InetAddr类对我们的代码进行修改:在获取到新连接后,将客户端的套接字信息打印

cpp 复制代码
void Start()
{
    _isrunning = true;
    while (_isrunning)
    {
        // 1. 获取新连接
        struct sockaddr_in peer;
        socklen_t peerlen = sizeof(peer);
        LOG(LogLevel::DEBUG) << "accept ing ...";
        int sockfd = ::accept(_listensockfd, CONV(&peer), &peerlen);
        if (sockfd < 0)
        {
            LOG(LogLevel::WARNING) << "accept error: " << strerror(errno);
            continue;
        }
        // 获取连接成功
        LOG(LogLevel::INFO) << "accept success, sockfd is: " << sockfd;
        InetAddr addr(peer);
        LOG(LogLevel::INFO) << "client info: " << addr.Addr();

        // 处理请求
        HandlerRequest(sockfd);
    }
}

4. Windows作为客户端访问Linux

cpp 复制代码
#include <winsock2.h>
#include <iostream>
#include <string>

#pragma warning(disable : 4996)

#pragma comment(lib, "ws2_32.lib")

std::string serverip = "47.113.120.114";  // 填写你的云服务器ip
uint16_t serverport = 8080; // 填写你的云服务开放的端口号

int main()
{
    WSADATA wsaData;
    int result = WSAStartup(MAKEWORD(2, 2), &wsaData);
    if (result != 0)
    {
        std::cerr << "WSAStartup failed: " << result << std::endl;
        return 1;
    }

    SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (clientSocket == INVALID_SOCKET)
    {
        std::cerr << "socket failed" << std::endl;
        WSACleanup();
        return 1;
    }

    sockaddr_in serverAddr;
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_port = htons(serverport);                  // 替换为服务器端口
    serverAddr.sin_addr.s_addr = inet_addr(serverip.c_str()); // 替换为服务器IP地址

    result = connect(clientSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr));
    if (result == SOCKET_ERROR)
    {
        std::cerr << "connect failed" << std::endl;
        closesocket(clientSocket);
        WSACleanup();
        return 1;
    }
    while (true)
    {
        std::string message;
        std::cout << "Please Enter@ ";
        std::getline(std::cin, message);
        if (message.empty()) continue;
        send(clientSocket, message.c_str(), message.size(), 0);

        char buffer[1024] = { 0 };
        int bytesReceived = recv(clientSocket, buffer, sizeof(buffer) - 1, 0);
        if (bytesReceived > 0)
        {
            buffer[bytesReceived] = '\0'; // 确保字符串以 null 结尾
            std::cout << "Received from server: " << buffer << std::endl;
        }
        else
        {
            std::cerr << "recv failed" << std::endl;
        }
    }

    closesocket(clientSocket);
    WSACleanup();

    return 0;
}

这里与UDP是十分类似的,就不过多介绍了。

我们现在让Linux上的客户端和Windows上的客户端都连接Linux上的服务端。



此时会发现,我们的服务端现在只能连接一个客户端。


当之前的客户端与服务端断开连接之后,服务端会立刻建立连接,并接收到之前没接收到的数据。我们当前的服务器一次只能处理一个请求,这显然是不行的。

注意:一定要先退出客户端,再退出服务端。如果先退出了服务端,这个服务端是不能立刻重启的,通常需要等待60到120秒。

解决服务器无法一次处理多个请求的问题

多进程版本
cpp 复制代码
void Start()
{
    _isrunning = true;
    while (_isrunning)
    {
        // 1. 获取新连接
        struct sockaddr_in peer;
        socklen_t peerlen = sizeof(peer);
        LOG(LogLevel::DEBUG) << "accept ing ...";
        int sockfd = ::accept(_listensockfd, CONV(&peer), &peerlen);
        if (sockfd < 0)
        {
            LOG(LogLevel::WARNING) << "accept error: " << strerror(errno);
            continue;
        }
        // 获取连接成功
        LOG(LogLevel::INFO) << "accept success, sockfd is: " << sockfd;
        InetAddr addr(peer);
        LOG(LogLevel::INFO) << "client info: " << addr.Addr();

        // 处理请求
        pid_t id = fork();
        if (id == 0)
        {
            // child
            ::close(_listensockfd);
            HandlerRequest(sockfd);
            exit(0);
        }

        // father
        ::close(sockfd);
        int rid = ::waitpid(id, nullptr, 0);
        if (rid < 0)
        {
            LOG(LogLevel::WARNING) << "waitpid error";
        }
    }
}

我们让创建的子进程来处理客户端的请求。子进程是会继承父进程的文件描述符表的,所以子进程是可以看到父进程打开的所有文件的文件描述符的,但是注意,是父子进程各有一张文件描述符表。子进程并不需要使用监听的文件描述符,父进程因为将处理客户端请求的任务已经交给了子进程,所以也不需要客户端的文件描述符。所以,父子进程均需要关闭掉不需要的文件描述符

此时会有一个问题,父进程是阻塞等待子进程的,而子进程处理接收任务是一个死循环,若是子进程一直不退出,父进程就会一直阻塞在这里,不还是只能处理一个请求吗?又必须要等待子进程,若不等待,子进程退出时就会有僵户问题。此时可以将子进程退出时给父进程发送的信号忽略掉,这样子进程退出,OS会自动回收资源,不用在wait了。这种做法是可以的,但是我们今天不使用,而是使用下面的做法:

cpp 复制代码
void Start()
{
    _isrunning = true;
    while (_isrunning)
    {
        // 1. 获取新连接
        struct sockaddr_in peer;
        socklen_t peerlen = sizeof(peer);
        LOG(LogLevel::DEBUG) << "accept ing ...";
        int sockfd = ::accept(_listensockfd, CONV(&peer), &peerlen);
        if (sockfd < 0)
        {
            LOG(LogLevel::WARNING) << "accept error: " << strerror(errno);
            continue;
        }
        // 获取连接成功
        LOG(LogLevel::INFO) << "accept success, sockfd is: " << sockfd;
        InetAddr addr(peer);
        LOG(LogLevel::INFO) << "client info: " << addr.Addr();

        // 处理请求
        pid_t id = fork();
        if (id == 0)
        {
            // child
            ::close(_listensockfd);
            // 子进程创建孙子进程
            if (fork() > 0) exit(0);
            // 子进程创建完成直接退出,孙子进程变成孤儿进程
            HandlerRequest(sockfd);
            exit(0);
        }

        // father
        ::close(sockfd);
        // 不会阻塞
        int rid = ::waitpid(id, nullptr, 0);
        if (rid < 0)
        {
            LOG(LogLevel::WARNING) << "waitpid error";
        }
    }
}

我们让子进程创建孙子进程,子进程退出后,父进程直接回收他。所以,爷爷进程不会阻塞在等待处了。爷爷进程不会管孙子进程,孙子进程就变成了孤儿进程,孤儿进程退出时,由1号进程进行回收。我们让Linux上的客户端和Windows上的客户端都连接Linux上的服务端。


可以看到,服务端就可以一次处理多个请求了。当有多个客户端时,是会有多个进程的。并且这些客户端的文件描述符都是4,因为客户端的父进程创建完子进程都,就将4这个文件描述符关闭了。

多进程版本是不太好的,因为创建进程、回收进程的工作量都是比较大的。并且这里不能使用之前实现的进程池,因为这里服务端要与客户端通信,子进程一定要能看到客户端的文件描述符,所以需要先有文件描述符,再创建子进程,而进程池是先创建出子进程,所以是看不到客户端的文件描述符的。当然有进程间传递文件描述符的技术,但是这并不是重点。

多线程版本

主线程和新线程是共享一强张文件描述符表的所以这里不能像上面一样关闭文件描述符。

线程要接收来自客户端的消息、向客户端发送消息,就一定要拿到客户端的文件描述符,所以要将客户端的文件描述符交给线程,怎么交给线程呢?肯定是将文件描述符传给线程执行的函数,但是要注意,一定不能将客户端的文件描述符的地址或值传过去,因为这个文件描述符是一个临时变量。将临时变量的地址传过去,可能将这个变量的地址交给线程后,就去调度其他线程了,等下次再调度到处理这个文件描述符的线程时,这个文件描述符对应的栈空间已经被其他临时变量覆盖了,此时就拿不到原先的文件描述符了。可以在堆上申请一块空间,将这块空间的值赋值为文件描述符,再将堆上的地址传过去。

cpp 复制代码
// 处理请求
pthread_t tid;
int* sockfdp = new int(sockfd);
pthread_create(&tid, nullptr, HandlerRequest, sockfdp);

因为HandlerRequest不符合线程调用函数的条件,所以我们定义一个符合条件的函数,并让线程去调用它,在它内部再去调用HandlerRequest。这里一定要将这个函数设置成static的,否则成员函数会有一个默认的参数,导致不符合要求,但是定义成static就无法访问类内的成员函数了,所以,我们定义一个结构体来传参。

cpp 复制代码
class TcpServer
{
private:
    struct ThreadData
    {
        int sockfd;
        TcpServer* self;
    };
    static void* ThreadEntry(void* args)
    {
        // 将新线程设置为分离状态,主线程就不用等待它了
        pthread_detach(pthread_self());
        ThreadData* data = (ThreadData*)args;
        data->self->HandlerRequest(data->sockfd);
        return nullptr;
    }
public:
    TcpServer(int port = gport):_port(port), _isrunning(false)
    {}
    void InitServer()
    {
        // 1. 创建TCP套接字
        _listensockfd = ::socket(AF_INET, SOCK_STREAM, 0);
        if(_listensockfd < 0)
        {
            LOG(LogLevel::FATAL) << "socket error";
            Die(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "socket create success, sockfd is: " << _listensockfd;

        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = INADDR_ANY;

        // 2. 绑定
        int n = ::bind(_listensockfd, CONV(&local), sizeof(local));
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            Die(BIND_ERR);
        }
        LOG(LogLevel::INFO) << "bind success, sockfd is: " << _listensockfd;

        // 3. 将套接字设置为监听状态
        n = ::listen(_listensockfd, BACKLOG);
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "listen errno";
            Die(LISTEN_ERR);
        }
        LOG(LogLevel::INFO) << "listen success, sockfd is: " << _listensockfd;
    }
    void HandlerRequest(int sockfd)
    {
        LOG(LogLevel::INFO) << "HandlerRequest, sockfd is: " << sockfd;
        char inbuffer[4096];
        while(true)
        {
            ssize_t n = ::read(sockfd, inbuffer, sizeof(inbuffer) - 1);
            if(n > 0)
            {
                LOG(LogLevel::INFO) << inbuffer;

                inbuffer[n] = '\0';
                std::string echo_str = "server echo# ";
                echo_str += inbuffer;

                ::write(sockfd, echo_str.c_str(), echo_str.size());
            }
            else if(n == 0)
            {
                // 客户端已经退出了,应该退出处理逻辑,重新获取连接
                LOG(LogLevel::INFO) << "client quit: " << sockfd;
                break;
            }
            else
            {
                // 读取失败
                break;
            }
        }
        ::close(sockfd);
    }
    void Start()
    {
        _isrunning = true;
        while(_isrunning)
        {
            // 1. 获取新连接
            struct sockaddr_in peer;
            socklen_t peerlen = sizeof(peer);
			LOG(LogLevel::DEBUG) << "accept ing ...";
            int sockfd = ::accept(_listensockfd, CONV(&peer), &peerlen);
            if(sockfd < 0)
            {
                LOG(LogLevel::WARNING) << "accept error: " << strerror(errno);
                continue;
            }
            // 获取连接成功
            LOG(LogLevel::INFO) << "accept success, sockfd is: " << sockfd;
            InetAddr addr(peer);
            LOG(LogLevel::INFO) << "client info: " << addr.Addr();

            // 处理请求
            pthread_t tid;
            ThreadData* data = new ThreadData;
            data->sockfd = sockfd;
            data->self = this;
            pthread_create(&tid, nullptr, ThreadEntry, data);
        }
    }
    void Stop()
    {
        _isrunning = false;
    }
    ~TcpServer()
    {}
private:
    int _listensockfd; // 监听套接字
    uint16_t _port;
    bool _isrunning;
};

可以看到,当有多个客户端连接时,这些客户端的文件描述符就不再都是4了。

每一次客户端向服务器发出请求时,也也就是客户端请求服务时才创建线程,效率较低。此时可以使用线程池。

线程池版本

使用到的线程池:

cpp 复制代码
namespace ThreadPoolMoudle
{
    using namespace LockMoudle;
    using namespace LogMoudule;
    using namespace ThreadModule;
    using namespace CondMoudle;

    // 用来做测试的线程方法
    void DefaultTest()
    {
        while(true)
        {
            LOG(LogLevel::DEBUG) << "我是一个测试方法";
            sleep(1);
        }
    }

    // 线程池中默认创建5个线程
    const static int defaultnum = 5;

    // thread_t就是一个指向Thread对象的智能指针
    using thread_t = std::shared_ptr<Thread>;

    // 这个模板表示的是消息队列中存放类型T
    template<typename T>
    class ThreadPool
    {
    private:
        bool IsEmpty() { return _taskq.empty(); }
        void HandlerTask(std::string name)
        {
            LOG(LogLevel::INFO) << "线程: " << name << ", 线程进入HandlerTask的逻辑";
            // LOG(LogLevel::INFO) << "线程进入HandlerTask的逻辑";
            while(true)
            {
                // 1. 拿任务
                T t;
                {
                    LockGuard lockguard(_lock);
                    // 当任务队列为空,并且线程池正在运行时,才能让线程等待
                    while(IsEmpty() && _isrunning) 
                    {
                        // 队列为空,等待
                        _wait_num ++;
                        _cond.Wait(_lock);
                        _wait_num --;
                    }

                    // 队列为空,并且线程池退出了,就让线程退出
                    if(IsEmpty() && !_isrunning)
                        break;

                    t = _taskq.front();
                    _taskq.pop();
                }
                // 2. 处理任务
                t(); // 规定:未来所有的任务处理,全部都必须提供()方法
            }
            LOG(LogLevel::INFO) << "线程: " << name << " 退出";
        }
        ThreadPool(const ThreadPool<T>&) = delete;
        ThreadPool<T>& operator=(const ThreadPool<T>&) = delete;
        // 默认线程池是没有运行的
        ThreadPool(int num = defaultnum) : _num(num), _wait_num(0), _isrunning(false)
        {
            // 创建出_num个线程对象
            for(int i = 0;i < _num;i ++)
            {
                _threads.push_back(std::make_shared<Thread>(std::bind(&ThreadPool::HandlerTask, this, std::placeholders::_1)));
                LOG(LogLevel::INFO) << "构建线程" << _threads.back()->Name() << "对象...成功";
            }
        }
    public:
        // 创建对象时就调用这个函数创建
        static ThreadPool<T>* getInstance()
        {
            if(instance == nullptr)
            {
                LOG(LogLevel::INFO) << "单例首次被执行, 需加载对象...";
                instance = new ThreadPool<T>();
                instance->Start();
            }
            return instance;
        }
        // 向任务队列放入一个线程
        void Equeue(T& in)
        {
            LockGuard lockguard(_lock);
            if(!_isrunning) return ;
            _taskq.push(std::move(in));
            // 若有处于等待状态的线程,唤醒
            if(_wait_num > 0)
                _cond.Notify();
        }
        // 创建线程池
        void Start()
        {
            // 让_num个线程对象都启动
            if(_isrunning) return ;
            _isrunning = true;
            for(auto& thread_ptr : _threads)
            {
                thread_ptr->Start();
                LOG(LogLevel::INFO) << "启动线程" << thread_ptr->Name() << " ...成功";
            }
        }
        // 停止线程池
        void Stop()
        {
            LockGuard lockguard(_lock);
            if(_isrunning)
            {
                // 不能再向任务队列中放入任务
                _isrunning = false;
                // 唤醒所有线程,并将任务队列中剩余的任务处理完成,此时已经无法向任务队列中放入任务了,所以任务是有限的
                if(_wait_num > 0)
                    _cond.NotifyAll();
            }
        }
        // 等待线程
        void Wait()
        {
            // 等待线程池中的所有线程
            for(auto& thread_ptr : _threads)
            {
                thread_ptr->Join();
                LOG(LogLevel::INFO) << "回收线程" << thread_ptr->Name() << " ...成功";
            }
        }
        ~ThreadPool() {}
    private:
        std::vector<thread_t> _threads; // 数组存放线程的指针
        int _num;                       // 线程池中线程个数
        int _wait_num;                  // 处于等待状态的线程数量
        bool _isrunning;                // 线程池是否正在运行
        std::queue<T> _taskq;           // 任务队列

        Mutex _lock;
        Cond _cond;

        static ThreadPool<T>* instance;
    };

    // 在类外对static的指针进行初始化
    template<typename T>
    ThreadPool<T>* ThreadPool<T>::instance = NULL;
}

首先要定义一个任务类型,这个任务类型是根据线程池来的,线程池中的是无参的,所以这里也要是无参的。

cpp 复制代码
// 交给线程池的任务的类型
using task_t = std::function<void()>;

接下来就是根据客户端的文件描述符构建任务,并将任务交给线程池。

cpp 复制代码
void Start()
{
    _isrunning = true;
    while (_isrunning)
    {
        // 1. 获取新连接
        struct sockaddr_in peer;
        socklen_t peerlen = sizeof(peer);
        LOG(LogLevel::DEBUG) << "accept ing ...";
        int sockfd = ::accept(_listensockfd, CONV(&peer), &peerlen);
        if (sockfd < 0)
        {
            LOG(LogLevel::WARNING) << "accept error: " << strerror(errno);
            continue;
        }
        // 获取连接成功
        LOG(LogLevel::INFO) << "accept success, sockfd is: " << sockfd;
        InetAddr addr(peer);
        LOG(LogLevel::INFO) << "client info: " << addr.Addr();

        // 处理请求, 构建任务, 并将任务交给线程池
        task_t f = std::bind(&TcpServer::HandlerRequest, this, sockfd);
        ThreadPool<task_t>::getInstance()->Equeue(f);
    }
}

这里要说明一下,今天的HandlerRequest中,一旦连接上了,就会一直处理,直到客户端退出,这种任务叫做长任务。但是线程池中的线程个数是有上限的,所以是不推荐使用线程池来处理长任务的,线程池一般用于处理短任务,比如登录、注销、请求数据等。这里就不改了,因为这样方便测试。线程池除了适合处理短任务,还适合处理用户量较少的任务。

现在,我们已经完成了客户端发消息给服务器,发给服务器的消息统一当成了字符串处理。在这里服务端从客户端读取、向客户端写入是不完善的,可能会出现客户端发送给服务端一个hello world,而服务端只读到了一个hello的情况,因为TCP是面向字节流的。UDP就不存在这样的问题,UDP是面向数据报,只要接收成功了,读取到的一定是完整的报文。面向字节流读取到的数据的格式是需要我们自己处理的。

对于面向字节流的读取和发送,更加推荐使用recv和send。当然,即使使用了recv和send,仍然存在读取报文可能不全的情况。

cpp 复制代码
#include <sys/socket.h>

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

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

第4个参数是读取标志位,设置为0即可,0表示是阻塞读取。返回值与read、write相同。

cpp 复制代码
void HandlerRequest(int sockfd)
{
    LOG(LogLevel::INFO) << "HandlerRequest, sockfd is: " << sockfd;
    char inbuffer[4096];
    while (true)
    {
        ssize_t n = ::recv(sockfd, inbuffer, sizeof(inbuffer) - 1, 0);
        if (n > 0)
        {
            LOG(LogLevel::INFO) << inbuffer;

            inbuffer[n] = '\0';
            std::string echo_str = "server echo# ";
            echo_str += inbuffer;

            ::send(sockfd, echo_str.c_str(), echo_str.size(), 0);
        }
        else if (n == 0)
        {
            // 客户端已经退出了,应该退出处理逻辑,重新获取连接
            LOG(LogLevel::INFO) << "client quit: " << sockfd;
            break;
        }
        else
        {
            // 读取失败
            break;
        }
    }
    ::close(sockfd);
}

V2版本 - 多线程远程执行命令

在上面的代码中,服务端接收了客户端发来的消息后,并没有做任何的处理,直接就发送回去了。客户端将数据发送给客户端,肯定是要服务端对数据进行处理的,所以,我们给客户端引入一些服务。服务端将客户端发送过来的消息当成是Linux指令,服务端接收到来自客户端的消息后,对消息进行分析,若是合理的就进行执行,并将执行结果发送回客户端。

此时需要有一个处理上层任务的入口。

cpp 复制代码
// 上层业务
using handler_t = std::function<std::string (std::string)>;

传入一个命令,也就是字符串,再将执行结果返回。在使用服务器时,要让服务器执行什么任务也需要传入。

cpp 复制代码
class TcpServer
{
public:
    TcpServer(handler_t handler, int port = gport):_handler(handler), _port(port), _isrunning(false)
    {}
    void HandlerRequest(int sockfd)
    {
        LOG(LogLevel::INFO) << "HandlerRequest, sockfd is: " << sockfd;
        char inbuffer[4096];
        while(true)
        {
            ssize_t n = ::recv(sockfd, inbuffer, sizeof(inbuffer) - 1, 0);
            if(n > 0)
            {
                LOG(LogLevel::INFO) << inbuffer;

                inbuffer[n] = '\0';
                // 调用上层的处理方法处理任务
                std::string cmd_result = _handler(inbuffer);

                ::send(sockfd, cmd_result.c_str(), cmd_result.size(), 0);
            }
            else if(n == 0)
            {
                // 客户端已经退出了,应该退出处理逻辑,重新获取连接
                LOG(LogLevel::INFO) << "client quit: " << sockfd;
                break;
            }
            else
            {
                // 读取失败
                break;
            }
        }
        ::close(sockfd);
    }
private:
    int _listensockfd; // 监听套接字
    uint16_t _port;
    bool _isrunning;

    // 处理上层任务的入口
    handler_t _handler;
};

创建一个类来定义处理任务的函数

cpp 复制代码
class Command
{
public:
    std::string Execute(std::string cmdstr)
    {
        
    }
};

Execute总体思路是父进程拿到命令字符串后,创建一个子进程,让子进程来执行命令,再将命令执行结果交给父进程。但是,进程有独立性,所以子进程要将执行结果交给父进程就需要进程间通信,此处使用管道,对子进程做输出重定向,重定向到管道里,父进程从读端就能够拿到命令执行结果了。exec并不会影响dup2的结果。总结:

  • 父进程创建一个管道
  • 父进程创建一个子进程,子进程输出重定向到管道中,执行命令
  • 父进程从管道中读取命令执行的结果

此时可以直接使用popen函数。

cpp 复制代码
#include <stdio.h>

FILE *popen(const char*command,const char *type);
int pclose(FILE *stream);

popen用于创建管道并启动一个子进程来执行shel命令。第一个参数是shell命令,第二个参数:

  • " r " :从命令的输出读取(父进程读取子进程的输出)
  • " w ":向命令的输入写入(父进程向子进程提供输入)

成功时返回一个文件指针,可用于读取或写入(取决于mode),失败时返回NULL。

cpp 复制代码
std::string Execute(std::string cmdstr)
{
    FILE* fp = ::popen(cmdstr.c_str(), "r");
    if (nullptr == fp)
    {
        return std::string("Failed");
    }
    char buffer[1024];
    std::string result;
    while (true)
    {
        char* ret = ::fgets(buffer, sizeof(buffer), fp);
        if (!ret) break;
        result += ret;
    }
    pclose(fp);
    return result.empty() ? std::string("Done") : result;
}

当前这个函数是没办法执行所有命令的。所以,我们要对执行的命令进行限制,设置一个白名单,规定那些命令可以执行。

cpp 复制代码
class Command
{
private:
    bool SafeCheck(const std::string& cmdstr)
    {
        auto iter = _white_list.find(cmdstr);
        return iter == _white_list.end() ? false : true;
    }
public:
    Command()
    {
        _white_list.insert("ls");
        _white_list.insert("pwd");
        _white_list.insert("ls -l");
        _white_list.insert("ll");
        _white_list.insert("touch");
        _white_list.insert("who");
        _white_list.insert("whoami");
    }
    std::string Execute(std::string cmdstr)
    {
        if(!SafeCheck(cmdstr))
        {
            return std::string(cmdstr + " 不支持");
        }

        FILE* fp = ::popen(cmdstr.c_str(), "r");
        if(nullptr == fp)
        {
            return std::string("Failed");
        }
        char buffer[1024];
        std::string result;
        while(true)
        {
            char* ret = ::fgets(buffer, sizeof(buffer), fp);
            if(!ret) break;
            result += ret;
        }
        pclose(fp);
        return result.empty() ? std::string("Done") : result;
    }
private:
    std::set<std::string> _white_list;
};
cpp 复制代码
int main()
{
    ENABLE_CONSOLE_LOG();
    Command cmd;
    
    std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>([&cmd](std::string cmdstr){
        return cmd.Execute(cmdstr);
    });
    tsvr->InitServer();
    tsvr->Start();
    return 0;
}
相关推荐
hrrrrb6 分钟前
【TCP/IP】12. 文件传输协议
服务器·网络·tcp/ip
网安小白的进阶之路3 小时前
A模块 系统与网络安全 第四门课 弹性交换网络-2
网络·安全·web安全·系统安全·交换机
安全系统学习3 小时前
网络安全之RCE分析与利用详情
服务器·网络·安全·web安全·系统安全
武汉唯众智创3 小时前
网络安全实训室建设方案全攻略
网络·安全·web安全·网络安全·网络安全实训室·网络安全实验室
longze_73 小时前
Ubuntu连接不上网络问题(Network is unreachable)
linux·服务器·ubuntu
Dirschs4 小时前
【Ubuntu22.04安装ROS Noetic】
linux·ubuntu·ros
qianshanxue114 小时前
ubuntu 操作记录
linux
啟明起鸣4 小时前
【网络编程】简易的 p2p 模型,实现两台虚拟机之间的简单点对点通信,并以小见大观察 TCP 协议的具体运行
c语言·网络·tcp/ip·p2p
追烽少年x4 小时前
设计模式---观察者模式(发布-订阅模式)
网络·设计模式
宝山哥哥6 小时前
网络信息安全学习笔记1----------网络信息安全概述
网络·笔记·学习·安全·网络安全