【网络】套接字(Socket)编程——TCP版

上一篇我们已经完整实现了 UDP Socket 编程。

UDP Server 的核心流程比较简单:

text 复制代码
socket()
   ↓
bind()
   ↓
recvfrom()
   ↓
业务处理
   ↓
sendto()

但 TCP 和 UDP 有一个非常关键的区别:

TCP 是面向连接的。

因此 TCP 在正式通信之前,必须先建立连接。

服务器和客户端的流程也就变成了:

text 复制代码
TCP Server

socket()
   ↓
bind()
   ↓
listen()
   ↓
accept()
   ↓
read() / write()
text 复制代码
TCP Client

socket()
   ↓
connect()
   ↓
read() / write()

这一篇就沿着这两条主线真正写出一个 TCP Server,并在此基础上一步步解决:

text 复制代码
单客户端
    ↓
多客户端
    ↓
多进程
    ↓
多线程
    ↓
线程池
    ↓
守护进程

最后再回到 TCP 本身,从内核角度理解:

text 复制代码
连接到底是什么?
accept 和三次握手是什么关系?
为什么 TCP 是全双工?
read/write 到底在干什么?
为什么 TCP 必须解决消息边界问题即粘包问题?

1. 先把最基本的 TCP 通信跑起来

1.1 程序结构

和 UDP 版本一样,我们先建立几个最基本的文件:

text 复制代码
TcpServer
├── server.hpp
├── server.cc
├── client.hpp
├── client.cc
└── Makefile

最终实现的仍然是最简单的 Echo Server:

text 复制代码
Client
   ↓
发送 hello
   ↓
Server
   ↓
收到 hello
   ↓
原样返回 hello
   ↓
Client

先准备 Makefile

makefile 复制代码
.PHONY:all
all:server client

server:server.cc
	g++ -o $@ $^ -std=c++11

client:client.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -rf server client

1.2 Server:socket 和 bind

首先创建服务器 Socket。

UDP 中:

cpp 复制代码
socket(AF_INET, SOCK_DGRAM, 0);

TCP 中:

cpp 复制代码
socket(AF_INET, SOCK_STREAM, 0);

区别就在第二个参数:

text 复制代码
SOCK_DGRAM
↓
UDP 数据报

SOCK_STREAM
↓
TCP 字节流

因此:

cpp 复制代码
listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);

创建的是一个 TCP Socket。

接着仍然需要绑定服务器自己的:

text 复制代码
IP
+
Port
cpp 复制代码
struct sockaddr_in local;
memset(&local, 0, sizeof(local));

local.sin_family = AF_INET;
local.sin_addr.s_addr = inet_addr(ip_.c_str());
local.sin_port = htons(port_);

bind(listen_sock_, (const struct sockaddr*)&local, sizeof(local));

这里和 UDP Server 基本一致。

端口仍然需要:

cpp 复制代码
htons();

转换为网络字节序。

到这里:

text 复制代码
UDP Server

已经可以直接等待数据了。

但 TCP 不行。

因为 TCP 是:

面向连接的。

服务器还必须告诉操作系统:

我这个 Socket 接下来不是主动连接别人,而是专门等待别人来连接我。

这就是:

cpp 复制代码
listen();

1.3 listen:把 Socket 变成监听 Socket

函数原型:

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

int listen(int sockfd, int backlog);

使用:

cpp 复制代码
listen(listen_sock_, backlog);

第一个参数:

cpp 复制代码
sockfd

就是之前:

cpp 复制代码
socket();

创建并通过:

cpp 复制代码
bind();

绑定好的 Socket。

listen() 做完以后,这个 Socket 就从一个普通 TCP Socket 变成了:

监听 Socket。

它以后主要负责:

text 复制代码
等待连接
+
获取连接

而不是直接和客户端收发业务数据。


backlog 到底是什么?

我们这一篇暂时不用继续深入,只需要记住:

text 复制代码
backlog
↓
等待 accept 的连接队列长度

另外:

cpp 复制代码
listen();

本身通常不会阻塞等待客户端。

真正负责:

没有连接就一直等。

的是下面的:

cpp 复制代码
accept();

1.4 accept:真正获取一个连接

函数原型:

cpp 复制代码
int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen);

使用:

cpp 复制代码
struct sockaddr_in client;
socklen_t len = sizeof(client);

int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);

在默认阻塞模式下,如果当前没有已经建立好的连接:

text 复制代码
accept()
   ↓
阻塞等待

一旦有客户端成功建立 TCP 连接:

text 复制代码
accept()
   ↓
返回

但这里最重要的不是阻塞。

而是它的返回值:

cpp 复制代码
service_sock

为什么服务器需要两个 Socket?

这是 TCP Socket 编程中最重要的地方之一。

一开始:

cpp 复制代码
listen_sock_ = socket(...);

得到的是:

text 复制代码
监听 Socket

它负责:

text 复制代码
listen
accept

但是:

cpp 复制代码
accept();

成功以后会返回:

cpp 复制代码
service_sock

这个才是:

专门和当前客户端通信的 Socket。

所以服务器实际上是:

text 复制代码
                    ┌── service_sock A ↔ Client A
                    │
listen_sock ─accept─┼── service_sock B ↔ Client B
                    │
                    └── service_sock C ↔ Client C

假设有三个客户端连接服务器。

服务器一共会存在:

text 复制代码
1 个 listen_sock
+
3 个 service_sock

监听 Socket 继续等待新连接。

每一个 accept() 返回的 Socket,则负责和对应客户端通信。

所以这两个 Socket 的职责一定不能混:

text 复制代码
listen_sock
↓
只负责获取新连接

service_sock
↓
真正负责 read/write

accept 还能拿到客户端信息

和 UDP 的:

cpp 复制代码
recvfrom();

类似,accept() 的:

cpp 复制代码
addr
addrlen

也能够把客户端地址带回来。

例如:

cpp 复制代码
uint16_t clientport = ntohs(client.sin_port);

char clientip[32];
inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));

于是服务器能够知道:

text 复制代码
哪个 IP
+
哪个 Port

建立了连接。


1.5 当前阶段完整 Server

到这里,我们已经把 TCP Server 建立连接之前需要的几个核心接口学完了:

text 复制代码
socket()
   ↓
创建 TCP Socket
   ↓
bind()
   ↓
绑定服务器 IP + Port
   ↓
listen()
   ↓
进入监听状态
   ↓
accept()
   ↓
获取已经建立好的 TCP 连接

此时这个 Server 暂时还不会和客户端进行数据通信,它只完成一件事:

等待客户端建立连接,并打印客户端的 IP、Port 以及 accept() 返回的通信 Socket。

当前项目只有三个文件:

text 复制代码
TcpServer
├── server.hpp
├── server.cc
└── Makefile
server.hpp
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

namespace fxs_server
{
    const uint16_t default_port = 8877;
    const std::string default_ip = "0.0.0.0";
    const int backlog = 5;

    enum
    {
        USAGE_ERR = 1,
        SOCKET_ERR,
        BIND_ERR,
        LISTEN_ERR
    };

    class TcpServer
    {
    public:
        TcpServer(uint16_t port = default_port, const std::string& ip = default_ip)
            : ip_(ip), port_(port)
        {}

        ~TcpServer()
        {}

        void InitServer()
        {
            // 1.创建TCP监听套接字
            listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);

            if(listen_sock_ < 0)
            {
                std::cerr << "Create Socket Fail: "
                          << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create Socket Success: "
                      << listen_sock_ << std::endl;

            // 2.绑定IP和Port
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local));

            local.sin_family = AF_INET;
            local.sin_port = htons(port_);
            local.sin_addr.s_addr = inet_addr(ip_.c_str());

            if(bind(listen_sock_, (const struct sockaddr*)&local, sizeof(local)) < 0)
            {
                std::cerr << "Bind IP&&Port Fail: "
                          << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            std::cout << "Bind IP&&Port Success" << std::endl;

            // 3.进入监听状态
            if(listen(listen_sock_, backlog) < 0)
            {
                std::cerr << "Listen Fail: "
                          << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success" << std::endl;
        }

        void StartServer()
        {
            while(true)
            {
                // 4.获取已经建立好的连接
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);

                if(service_sock < 0)
                {
                    std::cerr << "Accept Fail: "
                              << strerror(errno) << std::endl;
                    continue;
                }

                // 5.获取客户端IP和Port
                char clientip[32];
                inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));

                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Get a new connection -> "
                          << "[" << clientip << ":" << clientport << "] "
                          << "service_sock: " << service_sock
                          << std::endl;

                // 当前阶段还没有学习数据通信,
                // 所以这里只验证accept能够成功获取连接
                close(service_sock);
            }
        }

    private:
        int listen_sock_;
        std::string ip_;
        uint16_t port_;
    };
}

server.cc
cpp 复制代码
#include <iostream>
#include <memory>
#include "server.hpp"

using namespace std;
using namespace fxs_server;

void Usage(const char* program)
{
    cout << "Usage:" << endl;
    cout << "\t" << program << " ServerPort" << endl;
}

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

    uint16_t port = stoi(argv[1]);

    unique_ptr<TcpServer> server(new TcpServer(port));

    server->InitServer();
    server->StartServer();

    return 0;
}

编译:

bash 复制代码
make

运行:

bash 复制代码
./server 8877

此时可以看到:

text 复制代码
Create Socket Success: ...
Bind IP&&Port Success
Listen Success

随后程序停在那里。

这并不是程序卡死了,而是:

cpp 复制代码
accept();

在默认阻塞模式下发现当前还没有已经建立好的连接,因此正在等待客户端。

现在,我们就可以真正找一个 TCP 客户端来验证它。


1.6 使用 telnet 测试 TCP 连接

目前我们还没有编写自己的 TCP Client。

但是为了验证:

text 复制代码
socket
bind
listen
accept

这一整套流程是否已经正常工作,可以暂时使用:

text 复制代码
telnet

充当 TCP Client。

如果 Ubuntu 中还没有安装:

bash 复制代码
sudo apt install telnet

首先保持 Server 正在运行:

bash 复制代码
./server 8877

然后再打开一个终端:

bash 复制代码
telnet 127.0.0.1 8877

此时客户端会主动向:

text 复制代码
127.0.0.1:8877

建立 TCP 连接。

Server 中原本阻塞的:

cpp 复制代码
accept();

就会立即返回,并打印类似:

text 复制代码
Get a new connection -> [127.0.0.1:xxxxx] service_sock: 4

其中:

text 复制代码
127.0.0.1
↓
客户端IP

xxxxx
↓
客户端由操作系统自动分配的临时Port

4
↓
accept返回的通信Socket文件描述符

整个过程:

text 复制代码
Server
↓
socket()
↓
bind()
↓
listen()
↓
accept()
↓
等待......

                Client
                  ↓
          telnet 127.0.0.1 8877
                  ↓
             TCP建立连接
                  ↓

Server accept()返回
↓
获得client IP + Port
↓
获得service_sock

这里 telnet 建立连接以后很快又会被 Server 断开,这是正常现象。

因为当前代码:

cpp 复制代码
accept();

成功以后只做了:

cpp 复制代码
打印连接信息
↓
close(service_sock)

我们目前只是为了验证:

TCP Server 已经能够成功监听并获取连接。

下一步学习:

cpp 复制代码
read();
write();

以后,服务器才会真正利用这个 service_sock 和客户端进行数据通信。


1.7 read 和 write:真正开始 TCP 通信

连接建立完成以后,接下来就是通信。

Linux 中:

Socket 也是文件。

所以我们完全可以直接使用以前学习文件时的:

cpp 复制代码
read();
write();

读取和发送网络数据。

服务器业务函数:

cpp 复制代码
void Service(int sock, const std::string& clientip, const uint16_t& clientport)
{
    char buff[1024];
    std::string who = clientip + "-" + std::to_string(clientport);

    while(true)
    {
        ssize_t n = read(sock, buff, sizeof(buff) - 1);

        if(n > 0)
        {
            buff[n] = '\0';

            std::cout << "Server get: "
                      << buff
                      << " from "
                      << who
                      << std::endl;

            std::string respond = func_(buff);

            write(sock, respond.c_str(), respond.size());
        }
        else if(n == 0)
        {
            std::cout << "Client "
                      << who
                      << " quit!"
                      << std::endl;

            close(sock);
            break;
        }
        else
        {
            std::cerr << "Read Fail! "
                      << strerror(errno)
                      << std::endl;

            close(sock);
            break;
        }
    }
}

这里:

cpp 复制代码
read();

一共有三种结果。


read > 0

表示:

text 复制代码
真正读取到了数据

返回值:

cpp 复制代码
n

就是实际读取的字节数。

因此:

cpp 复制代码
buff[n] = '\0';

把它临时当 C 风格字符串使用。


read == 0

这个特别重要。

对于 TCP Socket:

cpp 复制代码
read() == 0

不是:

对方给我发送了一个长度为 0 的普通消息。

而意味着:

TCP 字节流已经到达 EOF,通常说明对端已经有序关闭了这一方向的连接。

最常见的情况就是:

text 复制代码
客户端 close()
    ↓
TCP 通知服务器
    ↓
服务器 read()
    ↓
返回 0

于是服务器知道:

客户端退出了。

这也是 TCP 和 UDP 很明显的一个区别。

UDP 没有连接状态,所以对端程序退出时:

text 复制代码
Server

并不会自动知道。

TCP 因为维护连接,所以:

text 复制代码
read() == 0

可以让我们感知对端关闭。


read < 0

表示:

text 复制代码
读取发生错误

此时:

cpp 复制代码
errno

保存错误原因。


1.8 Client:socket 和 connect

TCP Client 初始化仍然先:

cpp 复制代码
sock_ = socket(AF_INET, SOCK_STREAM, 0);

客户端一般不需要像 Server 一样手动:

cpp 复制代码
bind();

因为:

客户端只要主动发起连接,操作系统就可以自动给它选择合适的本地 IP 和临时端口。

所以客户端真正新增的核心接口是:

cpp 复制代码
connect();

函数原型:

cpp 复制代码
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);

使用:

cpp 复制代码
struct sockaddr_in server;
memset(&server, 0, sizeof(server));

server.sin_family = AF_INET;
inet_aton(server_ip_.c_str(), &server.sin_addr);
server.sin_port = htons(server_port_);

connect(sock_, (const struct sockaddr*)&server, sizeof(server));

这里:

cpp 复制代码
bind()

传的是:

text 复制代码
自己的地址

而:

cpp 复制代码
connect()

传的是:

text 复制代码
对方 Server 的地址

connect 成功到底意味着什么?

这个地方必须说清楚。

调用:

cpp 复制代码
connect();

以后,真正负责建立 TCP 连接的不是我们的 C++ 代码。

而是:

客户端和服务器两边操作系统中的 TCP 协议栈。

connect() 会触发 TCP 建连流程。

当三次握手成功以后:

cpp 复制代码
connect();

就可以成功返回。

这里并不要求服务器应用层已经执行:

cpp 复制代码
accept();

因为:

text 复制代码
三次握手
↓
TCP 内核协议栈完成

accept()
↓
应用层从内核已经完成的连接中取走一个

这是两层完全不同的事情。

所以以后一定要记住:

connect 对应"主动发起连接",accept 对应"拿走已经建立好的连接结果",accept 并不负责完成三次握手。


连接失败重试

服务器可能暂时没有启动。

如果:

cpp 复制代码
connect();

失败就立即退出,体验并不好。

所以可以简单尝试数次:

cpp 复制代码
int cnt = 5;

while(cnt)
{
    int ret = connect(sock_, (const struct sockaddr*)&server, sizeof(server));

    if(ret == 0)
        break;

    std::cerr << "网络异常,正在进行重连... 剩余连接次数: "
              << --cnt
              << std::endl;

    sleep(1);
}

if(cnt == 0)
{
    std::cerr << "连接失败! "
              << strerror(errno)
              << std::endl;

    close(sock_);
    exit(CONNECT_ERR);
}

1.9 Client 获取服务

连接成功后:

text 复制代码
Client

同样使用:

cpp 复制代码
write();
read();

通信。

cpp 复制代码
void GetService()
{
    char buff[1024];
    std::string who = server_ip_ + "-" + std::to_string(server_port_);

    while(true)
    {
        std::string msg;

        std::cout << "Please Enter >> ";
        std::getline(std::cin, msg);

        write(sock_, msg.c_str(), msg.size());

        ssize_t n = read(sock_, buff, sizeof(buff) - 1);

        if(n > 0)
        {
            buff[n] = '\0';

            std::cout << "Client get: "
                      << buff
                      << " from "
                      << who
                      << std::endl;
        }
        else if(n == 0)
        {
            std::cout << "Server "
                      << who
                      << " quit!"
                      << std::endl;

            close(sock_);
            break;
        }
        else
        {
            std::cerr << "Read Fail! "
                      << strerror(errno)
                      << std::endl;

            close(sock_);
            break;
        }
    }
}

于是一个完整请求:

text 复制代码
Client getline()
    ↓
write()
    ↓
Server read()
    ↓
业务处理
    ↓
Server write()
    ↓
Client read()

基本 TCP Echo 就打通了。


1.10 基础版完整代码

server.hpp
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

namespace fxs_server
{
    const uint16_t default_port = 8877;
    const std::string default_ip = "0.0.0.0";
    const int backlog = 5;

    enum
    {
        USAGE_ERR = 1,
        SOCKET_ERR,
        BIND_ERR,
        LISTEN_ERR
    };

    using func_t = std::function<std::string(std::string)>;

    class TcpServer
    {
    public:
        TcpServer(const func_t& func, const uint16_t port = default_port, const std::string ip = default_ip)
            : ip_(ip), port_(port), func_(func)
        {}

        ~TcpServer()
        {}

        void InitServer()
        {
            // 1.创建监听套接字
            listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);

            if(listen_sock_ == -1)
            {
                std::cerr << "Create Socket Fail! "
                          << strerror(errno)
                          << std::endl;

                exit(SOCKET_ERR);
            }

            std::cout << "Create Socket Success! "
                      << listen_sock_
                      << std::endl;

            // 2.绑定IP和Port
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local));

            local.sin_family = AF_INET;
            local.sin_addr.s_addr = inet_addr(ip_.c_str());
            local.sin_port = htons(port_);

            if(bind(listen_sock_, (const struct sockaddr*)&local, sizeof(local)) < 0)
            {
                std::cerr << "Bind IP&&Port Fail: "
                          << strerror(errno)
                          << std::endl;

                exit(BIND_ERR);
            }

            // 3.进入监听状态
            if(listen(listen_sock_, backlog) < 0)
            {
                std::cerr << "Listen Fail: "
                          << strerror(errno)
                          << std::endl;

                exit(LISTEN_ERR);
            }
        }

        void StartServer()
        {
            while(true)
            {
                // 1.获取新连接
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);

                if(service_sock < 0)
                {
                    std::cerr << "Accept Fail: "
                              << strerror(errno)
                              << std::endl;
                    continue;
                }

                // 2.获得客户端信息
                uint16_t clientport = ntohs(client.sin_port);

                char clientip[32];
                inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));

                std::cout << "Server accept "
                          << clientip
                          << ":"
                          << clientport
                          << ", service sock: "
                          << service_sock
                          << std::endl;

                // 3.提供服务
                Service(service_sock, clientip, clientport);
            }
        }

        void Service(int sock, const std::string& clientip, const uint16_t& clientport)
        {
            char buff[1024];
            std::string who = clientip + "-" + std::to_string(clientport);

            while(true)
            {
                ssize_t n = read(sock, buff, sizeof(buff) - 1);

                if(n > 0)
                {
                    buff[n] = '\0';

                    std::cout << "Server get: "
                              << buff
                              << " from "
                              << who
                              << std::endl;

                    std::string respond = func_(buff);

                    write(sock, respond.c_str(), respond.size());
                }
                else if(n == 0)
                {
                    std::cout << "Client "
                              << who
                              << " quit!"
                              << std::endl;

                    close(sock);
                    break;
                }
                else
                {
                    std::cerr << "Read Fail! "
                              << strerror(errno)
                              << std::endl;

                    close(sock);
                    break;
                }
            }
        }

    private:
        int listen_sock_;
        std::string ip_;
        uint16_t port_;
        func_t func_;
    };
}
server.cc
cpp 复制代码
#include <iostream>
#include <memory>
#include "server.hpp"

using namespace std;
using namespace fxs_server;

std::string Echo(const std::string& request)
{
    return request;
}

void Usage(const char* program)
{
    cout << "Usage:" << endl;
    cout << "\t" << program << " ServerPort" << endl;
}

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

    uint16_t port = stoi(argv[1]);

    unique_ptr<TcpServer> server(new TcpServer(Echo, port));

    server->InitServer();
    server->StartServer();

    return 0;
}
client.hpp
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

namespace fxs_client
{
    enum
    {
        USAGE_ERR = 1,
        SOCKET_ERR,
        CONNECT_ERR
    };

    class TcpClient
    {
    public:
        TcpClient(const std::string& ip, const uint16_t port)
            : server_ip_(ip), server_port_(port)
        {}

        ~TcpClient()
        {}

        void InitClient()
        {
            sock_ = socket(AF_INET, SOCK_STREAM, 0);

            if(sock_ == -1)
            {
                std::cerr << "Create Socket Fail! "
                          << strerror(errno)
                          << std::endl;

                exit(SOCKET_ERR);
            }

            std::cout << "Create Socket Success! "
                      << sock_
                      << std::endl;
        }

        void StartClient()
        {
            struct sockaddr_in server;
            memset(&server, 0, sizeof(server));

            server.sin_family = AF_INET;
            inet_aton(server_ip_.c_str(), &server.sin_addr);
            server.sin_port = htons(server_port_);

            int cnt = 5;

            while(cnt)
            {
                int ret = connect(sock_, (const struct sockaddr*)&server, sizeof(server));

                if(ret == 0)
                    break;

                std::cerr << "网络异常,正在进行重连... 剩余连接次数: "
                          << --cnt
                          << std::endl;

                sleep(1);
            }

            if(cnt == 0)
            {
                std::cerr << "连接失败! "
                          << strerror(errno)
                          << std::endl;

                close(sock_);
                exit(CONNECT_ERR);
            }

            std::cout << "连接成功!" << std::endl;

            GetService();
        }

        void GetService()
        {
            char buff[1024];
            std::string who = server_ip_ + "-" + std::to_string(server_port_);

            while(true)
            {
                std::string msg;

                std::cout << "Please Enter >> ";
                std::getline(std::cin, msg);

                write(sock_, msg.c_str(), msg.size());

                ssize_t n = read(sock_, buff, sizeof(buff) - 1);

                if(n > 0)
                {
                    buff[n] = '\0';

                    std::cout << "Client get: "
                              << buff
                              << " from "
                              << who
                              << std::endl;
                }
                else if(n == 0)
                {
                    std::cout << "Server "
                              << who
                              << " quit!"
                              << std::endl;

                    close(sock_);
                    break;
                }
                else
                {
                    std::cerr << "Read Fail! "
                              << strerror(errno)
                              << std::endl;

                    close(sock_);
                    break;
                }
            }
        }

    private:
        int sock_;
        std::string server_ip_;
        uint16_t server_port_;
    };
}
client.cc
cpp 复制代码
#include <iostream>
#include <memory>
#include "client.hpp"

using namespace std;
using namespace fxs_client;

void Usage(const char* program)
{
    cout << "Usage:" << endl;
    cout << "\t" << program << " ServerIP ServerPort" << endl;
}

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

    std::string ip = argv[1];
    uint16_t port = stoi(argv[2]);

    unique_ptr<TcpClient> client(new TcpClient(ip, port));

    client->InitClient();
    client->StartClient();

    return 0;
}

1.9 运行测试

编译:

bash 复制代码
make

启动服务器:

bash 复制代码
./server 8877

启动客户端:

bash 复制代码
./client 127.0.0.1 8877

客户端输入:

text 复制代码
hello tcp

应该得到:

text 复制代码
Client
↓
hello tcp
↓
Server
↓
hello tcp
↓
Client

还可以查看当前 TCP 监听端口:

bash 复制代码
ss -ntlp | grep 8877
bash 复制代码
netsat -nltp | grep 8877

2. 单进程服务器为什么不能同时服务多个客户端?

现在打开两个 Client:

text 复制代码
Client A
Client B

A 先连接。

Server 执行:

cpp 复制代码
accept();

获得:

text 复制代码
A 的 service_sock

然后马上进入:

cpp 复制代码
Service();

而:

cpp 复制代码
Service();

内部是:

cpp 复制代码
while(true)

不断为 A 服务。

因此服务器此时的执行流:

text 复制代码
accept A
   ↓
Service A
   ↓
Service A
   ↓
Service A
   ↓
......

根本回不到下一次:

cpp 复制代码
accept();

所以 B 无法被应用层处理。


为什么 B 的 connect 却可能显示成功?

这个地方特别容易产生误解。

B:

cpp 复制代码
connect();

成功,只说明:

客户端和服务器内核中的 TCP 三次握手成功了。

并不代表:

服务器应用代码已经调用 accept() 并开始处理 B。

因此完全可能出现:

text 复制代码
Client B
↓
connect 成功

Server 应用
↓
还在 Service(A)

B 对应的连接已经在:

text 复制代码
内核

中建立完成,并等待:

cpp 复制代码
accept();

取走。

所以:

text 复制代码
connect 成功
≠
服务器业务代码已经开始服务你

这正好也说明:

连接建立属于内核 TCP 协议栈,而 accept 属于应用层获取连接。

问题真正出在:

text 复制代码
接受新连接

和:

text 复制代码
为旧连接提供服务

使用了同一个执行流。

所以接下来我们必须:

让"获取连接"和"处理连接"同时进行。

第一种方法:

text 复制代码
多进程

3. 多进程版 TCP Server

核心思路:

text 复制代码
父进程
↓
只负责 accept

子进程
↓
负责 Service

每建立一个新连接:

text 复制代码
accept()
   ↓
fork()
   ↓
┌───────────────┐
│               │
父进程          子进程
│               │
继续 accept      Service

3.1 为什么子进程能够直接使用 service_sock?

因为:

cpp 复制代码
fork();

以后,子进程会继承父进程的:

文件描述符表。

所以父进程刚刚:

cpp 复制代码
accept();

得到的:

text 复制代码
service_sock

子进程同样能够访问。

假设:

text 复制代码
父进程
fd 3 -> listen socket
fd 4 -> client socket

fork() 后:

text 复制代码
父进程                 子进程

fd 3 -> listen          fd 3 -> listen
fd 4 -> client          fd 4 -> client

但两边职责不同。

子进程:

text 复制代码
只负责当前 Client

所以不需要:

text 复制代码
listen_sock

应该:

cpp 复制代码
close(listen_sock_);

父进程:

text 复制代码
只负责继续 accept

所以不需要这个客户端的:

text 复制代码
service_sock

应该:

cpp 复制代码
close(service_sock);

注意:

这里父进程 close(service_sock) 并不会立即断开子进程和客户端的连接。

因为 fork() 后父子进程的文件描述符虽然是两份表项,但都引用内核中的同一个打开文件对象。

只有相关引用全部关闭,底层连接才真正失去引用。


3.2 最直接的多进程版本

cpp 复制代码
void StartServer()
{
    while(true)
    {
        struct sockaddr_in client;
        socklen_t len = sizeof(client);

        int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);

        if(service_sock < 0)
            continue;

        uint16_t clientport = ntohs(client.sin_port);

        char clientip[32];
        inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));

        pid_t id = fork();

        if(id < 0)
        {
            close(service_sock);
            continue;
        }
        else if(id == 0)
        {
            close(listen_sock_);

            Service(service_sock, clientip, clientport);

            exit(0);
        }

        close(service_sock);
    }
}

这样:

text 复制代码
Client A -> Child A
Client B -> Child B
Client C -> Child C

而父进程始终:

text 复制代码
accept
accept
accept

服务器就具备并发能力了。


3.3 子进程退出后谁来回收?

这里又出现进程编程中的老问题:

僵尸进程。

子进程完成:

cpp 复制代码
Service();

以后退出。

父进程如果完全不:

cpp 复制代码
wait();
waitpid();

就可能产生僵尸进程。

那是不是直接:

cpp 复制代码
waitpid(id, nullptr, 0);

就行?

也不行。

因为这是:

text 复制代码
阻塞等待

父进程就会变成:

text 复制代码
fork Client A
    ↓
wait Client A
    ↓
Client A 不退出
    ↓
父进程无法 accept B

又失去了并发意义。

所以我们需要:

既回收子进程,又不能阻塞父进程继续 accept。


3.4 四种处理思路

方法一:waitpid + WNOHANG
cpp 复制代码
waitpid(id, nullptr, WNOHANG);

这样:

text 复制代码
waitpid

本身不会阻塞。

但问题是:

如果调用 waitpid() 时子进程还没退出,它会直接返回。

以后子进程真正退出时,如果父进程正阻塞在:

cpp 复制代码
accept();

就可能暂时没人回收。

所以只是简单在:

cpp 复制代码
fork();

后调用一次:

cpp 复制代码
waitpid(..., WNOHANG);

并不能彻底解决问题。


方法二:捕获 SIGCHLD

子进程退出时,父进程会收到:

text 复制代码
SIGCHLD

所以可以注册信号处理函数:

cpp 复制代码
static void Handler(int signo)
{
    while(waitpid(-1, nullptr, WNOHANG) > 0)
    {}
}

然后:

cpp 复制代码
signal(SIGCHLD, Handler);

这样:

text 复制代码
父进程
↓
正常 accept

子进程退出
↓
SIGCHLD
↓
Handler
↓
waitpid

就能做到异步回收。

注意:

信号处理函数中应该尽量只调用异步信号安全的接口,不要随便塞大量 cout、内存分配等复杂逻辑。


方法三:显式忽略 SIGCHLD

Linux 下还有一种非常简单的方法:

cpp 复制代码
signal(SIGCHLD, SIG_IGN);

显式将:

text 复制代码
SIGCHLD

设置为忽略以后,Linux 可以自动处理这些退出子进程,不让它们长期处于僵尸状态。

对于我们现在这个简单 TCP Server:

cpp 复制代码
signal(SIGCHLD, SIG_IGN);

是最省事的写法。

因此最终:

cpp 复制代码
void StartServer()
{
    signal(SIGCHLD, SIG_IGN);

    while(true)
    {
        struct sockaddr_in client;
        socklen_t len = sizeof(client);

        int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);

        if(service_sock < 0)
            continue;

        uint16_t clientport = ntohs(client.sin_port);

        char clientip[32];
        inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));

        pid_t id = fork();

        if(id < 0)
        {
            close(service_sock);
            continue;
        }
        else if(id == 0)
        {
            close(listen_sock_);

            Service(service_sock, clientip, clientport);

            exit(0);
        }

        close(service_sock);
    }
}

方法四:双重 fork

还有一种经典技巧:

text 复制代码
父进程
↓
fork 子进程
↓
子进程再 fork 孙子进程
↓
子进程立即退出
↓
父进程只等待这个很快退出的子进程
↓
孙子进程真正 Service

原来的父子关系被切断以后,孙子进程会由系统中的父进程接管机制负责。

这种方式能够绕开父进程长期等待业务进程的问题。

不过:

对我们当前 TCP Server 来说,没有必要专门绕这么一大圈。

知道这个方法即可。

后面讲守护进程时,还会再次看到:

text 复制代码
fork
+
setsid
+
再次 fork

这种思路。


3.5 多进程版 TCP Server 完整代码

前面我们已经把多进程服务器中几个关键问题解决了:

text 复制代码
父进程
↓
accept()
↓
获取新连接
↓
fork()


父进程
↓
关闭自己的 service_sock
↓
继续 accept()


子进程
↓
关闭 listen_sock
↓
Service()
↓
专门服务当前客户端

同时,子进程退出以后还需要解决:

text 复制代码
僵尸进程

的问题。

在当前这个简单 TCP Server 中,我们采用:

cpp 复制代码
signal(SIGCHLD, SIG_IGN);

让 Linux 自动处理退出的子进程。

因此,现在可以把前面的内容真正组合起来,得到一份完整的多进程 TCP Server

当前代码结构:

text 复制代码
TcpServer
├── server.hpp
├── server.cc
├── client.hpp
├── client.cc
└── Makefile

客户端本身不需要发生变化,仍然使用前面已经写好的 TCP Client。

这一阶段真正修改的是 Server。


server.hpp
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

namespace fxs_server
{
    const uint16_t default_port = 8877;
    const std::string default_ip = "0.0.0.0";
    const int backlog = 5;

    enum
    {
        USAGE_ERR = 1,
        SOCKET_ERR,
        BIND_ERR,
        LISTEN_ERR
    };

    using func_t = std::function<std::string(std::string)>;

    class TcpServer
    {
    public:
        TcpServer(const func_t& func, uint16_t port = default_port, const std::string& ip = default_ip)
            : ip_(ip), port_(port), func_(func)
        {}

        ~TcpServer()
        {}

        void InitServer()
        {
            // 1.创建监听套接字
            listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);

            if(listen_sock_ < 0)
            {
                std::cerr << "Create Socket Fail: "
                          << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create Socket Success: "
                      << listen_sock_ << std::endl;

            // 2.绑定IP和Port
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local));

            local.sin_family = AF_INET;
            local.sin_port = htons(port_);
            local.sin_addr.s_addr = inet_addr(ip_.c_str());

            if(bind(listen_sock_, (const struct sockaddr*)&local, sizeof(local)) < 0)
            {
                std::cerr << "Bind IP&&Port Fail: "
                          << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            std::cout << "Bind IP&&Port Success" << std::endl;

            // 3.进入监听状态
            if(listen(listen_sock_, backlog) < 0)
            {
                std::cerr << "Listen Fail: "
                          << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success" << std::endl;
        }

        void Service(int sock, const std::string& clientip, uint16_t clientport)
        {
            char buff[1024];
            std::string who = clientip + ":" + std::to_string(clientport);

            while(true)
            {
                ssize_t n = read(sock, buff, sizeof(buff) - 1);

                if(n > 0)
                {
                    buff[n] = '\0';

                    std::cout << "Server get: "
                              << buff
                              << " from "
                              << who
                              << std::endl;

                    std::string respond = func_(buff);

                    write(sock, respond.c_str(), respond.size());
                }
                else if(n == 0)
                {
                    std::cout << "Client "
                              << who
                              << " quit!"
                              << std::endl;

                    close(sock);
                    break;
                }
                else
                {
                    std::cerr << "Read Fail: "
                              << strerror(errno)
                              << std::endl;

                    close(sock);
                    break;
                }
            }
        }

        void StartServer()
        {
            // 子进程退出后由系统自动处理,避免产生僵尸进程
            signal(SIGCHLD, SIG_IGN);

            while(true)
            {
                // 1.父进程获取新连接
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);

                if(service_sock < 0)
                {
                    std::cerr << "Accept Fail: "
                              << strerror(errno)
                              << std::endl;
                    continue;
                }

                // 2.获取客户端信息
                char clientip[32];
                inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));

                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Get a new connection -> "
                          << "[" << clientip << ":" << clientport << "] "
                          << "service_sock: " << service_sock
                          << std::endl;

                // 3.创建子进程
                pid_t id = fork();

                if(id < 0)
                {
                    std::cerr << "Fork Fail: "
                              << strerror(errno)
                              << std::endl;

                    close(service_sock);
                    continue;
                }
                else if(id == 0)
                {
                    // 子进程只负责当前客户端,不需要监听套接字
                    close(listen_sock_);

                    Service(service_sock, clientip, clientport);

                    exit(0);
                }

                // 父进程只负责继续获取新连接
                close(service_sock);
            }
        }

    private:
        int listen_sock_;
        std::string ip_;
        uint16_t port_;
        func_t func_;
    };
}

这里最核心的其实就是:

cpp 复制代码
pid_t id = fork();

之后父子进程职责彻底分开:

text 复制代码
父进程
↓
close(service_sock)
↓
回到 accept()
↓
继续等待其他客户端


子进程
↓
close(listen_sock_)
↓
Service(service_sock)
↓
一直服务当前客户端

所以此时:

text 复制代码
Client A
↓
Child Process A

Client B
↓
Child Process B

Client C
↓
Child Process C

父进程则始终专门负责:

cpp 复制代码
accept();

这就解决了前面单进程版本:

text 复制代码
Service(A)
↓
长期不返回
↓
无法继续 accept(B)

的问题。


server.cc

业务层仍然使用最简单的 Echo:

cpp 复制代码
#include <iostream>
#include <memory>
#include "server.hpp"

using namespace std;
using namespace fxs_server;

std::string Echo(const std::string& request)
{
    return request;
}

void Usage(const char* program)
{
    cout << "Usage:" << endl;
    cout << "\t" << program << " ServerPort" << endl;
}

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

    uint16_t port = stoi(argv[1]);

    unique_ptr<TcpServer> server(new TcpServer(Echo, port));

    server->InitServer();
    server->StartServer();

    return 0;
}

可以看到,业务层完全没有因为服务器从:

text 复制代码
单进程

改成:

text 复制代码
多进程

而发生变化。

变化的只是:

服务器如何调度不同客户端。


运行多进程 Server

重新编译:

bash 复制代码
make clean
make

启动服务器:

bash 复制代码
./server 8877

然后分别打开多个终端。

Client A:

bash 复制代码
./client 127.0.0.1 8877

Client B:

bash 复制代码
./client 127.0.0.1 8877

Client C:

bash 复制代码
./client 127.0.0.1 8877

此时三个客户端都可以持续和服务器通信。

例如:

text 复制代码
Client A
Please Enter >> hello A

Client B
Please Enter >> hello B

Client C
Please Enter >> hello C

三个客户端都能分别收到自己的 Echo。

这说明:

text 复制代码
父进程
↓
仍然可以不断 accept()

多个子进程
↓
同时为不同客户端提供 Service()

查看 Server 的多个进程

此时再打开一个终端:

bash 复制代码
ps -axj | head -1 && ps -axj | grep 8877 | grep -v grep

观察父子关系

应该可以看到:

text 复制代码
一个 Server 父进程
+
多个 Server 子进程

再观察一次文件描述符关系

这个版本还有一个特别值得复习的地方。

假设父进程:

text 复制代码
listen_sock = 3
service_sock = 4

执行:

cpp 复制代码
fork();

之后:

text 复制代码
父进程                 子进程

fd 3                    fd 3
↓                       ↓
listen Socket           listen Socket

fd 4                    fd 4
↓                       ↓
Client Socket           Client Socket

因为子进程继承了父进程的文件描述符表。

但是双方职责不同。

所以子进程:

cpp 复制代码
close(listen_sock_);

只保留:

text 复制代码
service_sock

用于服务客户端。

父进程:

cpp 复制代码
close(service_sock);

只保留:

text 复制代码
listen_sock

继续接受新连接。

最终:

text 复制代码
Parent
  │
  └── listen_sock
        │
        ├── accept A -> fork -> Child A -> service_sock A
        │
        ├── accept B -> fork -> Child B -> service_sock B
        │
        └── accept C -> fork -> Child C -> service_sock C

这就是当前多进程 TCP Server 的完整结构。


3.6 多进程版本还有什么问题?

现在服务器确实已经能够同时服务多个客户端了。

但是它采用的是:

text 复制代码
来一个客户端
↓
fork 一个子进程

再来一个客户端
↓
再 fork 一个子进程

进程的创建和维护成本并不低。

因为不同进程拥有各自独立的:

text 复制代码
虚拟地址空间
页表
PCB
资源管理结构
......

而我们的需求其实只是:

让不同客户端由不同执行流同时处理。

未必真的需要:

text 复制代码
多个独立进程

因此接下来可以考虑一种更轻量的执行流:

线程。

于是服务器继续演进:

text 复制代码
单进程串行
    ↓
多进程并发
    ↓
多线程并发

4. 多线程版 TCP Server

多进程版本能够工作。

但每来一个客户端就:

cpp 复制代码
fork();

仍然比较重。

不同进程:

text 复制代码
拥有独立虚拟地址空间

而线程:

text 复制代码
属于同一个进程
共享地址空间
共享文件描述符表
共享大量进程资源

所以对于这种:

text 复制代码
一个连接
↓
交给一个独立执行流

的问题,也可以使用线程。

整体结构:

text 复制代码
主线程
↓
accept
↓
创建新线程
↓
继续 accept

工作线程
↓
Service

4.1 ThreadData:怎么把参数传给线程?

pthread_create() 的线程入口形式:

cpp 复制代码
void* Routine(void*);

而:

cpp 复制代码
Service();

需要:

text 复制代码
service_sock
client IP
client Port
TcpServer 对象本身

一个:

cpp 复制代码
void*

显然直接装不下这么多独立参数。

所以把它们封装成一个对象:

cpp 复制代码
class ThreadData
{
public:
    ThreadData(int sock, const std::string& ip, uint16_t port, TcpServer* current)
        : sock_(sock), clientip_(ip), clientport_(port), current_(current)
    {}

public:
    int sock_;
    std::string clientip_;
    uint16_t clientport_;
    TcpServer* current_;
};

然后:

cpp 复制代码
ThreadData* td = new ThreadData(service_sock, clientip, clientport, this);

把:

cpp 复制代码
td

作为:

cpp 复制代码
void*

传给线程。


4.2 为什么线程函数是 static?

普通成员函数实际上隐藏了:

cpp 复制代码
this

参数。

而:

cpp 复制代码
pthread_create();

要求的是普通函数形式:

cpp 复制代码
void* (*)(void*);

所以类内线程入口一般写成:

cpp 复制代码
static
cpp 复制代码
static void* Routine(void* args)
{
    pthread_detach(pthread_self());

    ThreadData* td = static_cast<ThreadData*>(args);

    td->current_->Service(td->sock_, td->clientip_, td->clientport_);

    delete td;

    return nullptr;
}

其中:

cpp 复制代码
pthread_detach(pthread_self());

表示:

当前线程退出以后,系统自动回收线程资源,不要求主线程再 pthread_join()


4.3 多线程 StartServer

cpp 复制代码
void StartServer()
{
    while(true)
    {
        struct sockaddr_in client;
        socklen_t len = sizeof(client);

        int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);

        if(service_sock < 0)
            continue;

        uint16_t clientport = ntohs(client.sin_port);

        char clientip[32];
        inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));

        ThreadData* td = new ThreadData(service_sock, clientip, clientport, this);

        pthread_t tid;
        pthread_create(&tid, nullptr, Routine, td);
    }
}

与多进程版本有一个非常重要的区别。

多进程:

text 复制代码
父子进程有各自的文件描述符表

所以父进程可以:

cpp 复制代码
close(service_sock);

只关闭自己那一份引用。

但是线程之间:

text 复制代码
共享同一个进程的文件描述符表

所以主线程不能像多进程一样随便再:

cpp 复制代码
close(service_sock);

否则工作线程使用的也是这个文件描述符。


4.4 多线程版 TCP Server 完整代码

到这里,多线程版本需要的内容已经全部具备了:

text 复制代码
主线程
↓
accept()
↓
获得 service_sock
↓
构造 ThreadData
↓
pthread_create()
↓
继续 accept()


工作线程
↓
Routine()
↓
pthread_detach()
↓
Service()
↓
专门服务当前客户端

和多进程版本相比,它们解决的问题完全相同:

不能让一个客户端的 Service() 占住服务器唯一的执行流。

但是实现方式发生了变化:

text 复制代码
多进程
↓
一个客户端对应一个子进程

多线程
↓
一个客户端对应一个工作线程

现在把前面的内容组合起来,得到完整的多线程 TCP Server。

当前项目结构仍然是:

text 复制代码
TcpServer
├── server.hpp
├── server.cc
├── client.hpp
├── client.cc
└── Makefile

客户端代码不需要修改,继续使用前面已经完成的 TCP Client。

这一阶段真正修改的仍然只有 Server。


server.hpp
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

namespace fxs_server
{
    const uint16_t default_port = 8877;
    const std::string default_ip = "0.0.0.0";
    const int backlog = 5;

    enum
    {
        USAGE_ERR = 1,
        SOCKET_ERR,
        BIND_ERR,
        LISTEN_ERR
    };

    class TcpServer;

    // 将线程执行Service所需要的数据统一封装起来
    class ThreadData
    {
    public:
        ThreadData(int sock, const std::string& ip, uint16_t port, TcpServer* current)
            : sock_(sock), clientip_(ip), clientport_(port), current_(current)
        {}

    public:
        int sock_;
        std::string clientip_;
        uint16_t clientport_;
        TcpServer* current_;
    };

    using func_t = std::function<std::string(std::string)>;

    class TcpServer
    {
    public:
        TcpServer(const func_t& func, uint16_t port = default_port, const std::string& ip = default_ip)
            : ip_(ip), port_(port), func_(func)
        {}

        ~TcpServer()
        {}

        void InitServer()
        {
            // 1.创建监听套接字
            listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);

            if(listen_sock_ < 0)
            {
                std::cerr << "Create Socket Fail: "
                          << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create Socket Success: "
                      << listen_sock_ << std::endl;

            // 2.绑定IP和Port
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local));

            local.sin_family = AF_INET;
            local.sin_port = htons(port_);
            local.sin_addr.s_addr = inet_addr(ip_.c_str());

            if(bind(listen_sock_, (const struct sockaddr*)&local, sizeof(local)) < 0)
            {
                std::cerr << "Bind IP&&Port Fail: "
                          << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            std::cout << "Bind IP&&Port Success" << std::endl;

            // 3.进入监听状态
            if(listen(listen_sock_, backlog) < 0)
            {
                std::cerr << "Listen Fail: "
                          << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success" << std::endl;
        }

        void StartServer()
        {
            while(true)
            {
                // 1.主线程获取新连接
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);

                if(service_sock < 0)
                {
                    std::cerr << "Accept Fail: "
                              << strerror(errno) << std::endl;
                    continue;
                }

                // 2.获取客户端IP和Port
                char clientip[32];
                inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));

                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Get a new connection -> "
                          << "[" << clientip << ":" << clientport << "] "
                          << "service_sock: " << service_sock
                          << std::endl;

                // 3.构造线程需要的参数
                ThreadData* td = new ThreadData(service_sock, clientip, clientport, this);

                // 4.创建线程,让工作线程负责当前客户端
                pthread_t tid;
                pthread_create(&tid, nullptr, Routine, td);

                // 主线程不进入Service,直接继续下一轮accept
            }
        }

        // 工作线程入口函数
        static void* Routine(void* args)
        {
            // 分离当前线程,退出后自动回收资源
            pthread_detach(pthread_self());

            ThreadData* td = static_cast<ThreadData*>(args);

            // 调用当前TcpServer对象的Service
            td->current_->Service(td->sock_, td->clientip_, td->clientport_);

            delete td;

            return nullptr;
        }

        // 通信服务 + 业务处理
        void Service(int sock, const std::string& clientip, uint16_t clientport)
        {
            char buff[1024];
            std::string who = clientip + ":" + std::to_string(clientport);

            while(true)
            {
                ssize_t n = read(sock, buff, sizeof(buff) - 1);

                if(n > 0)
                {
                    buff[n] = '\0';

                    std::cout << "Server get: "
                              << buff
                              << " from "
                              << who
                              << std::endl;

                    std::string respond = func_(buff);

                    write(sock, respond.c_str(), respond.size());
                }
                else if(n == 0)
                {
                    std::cout << "Client "
                              << who
                              << " quit!"
                              << std::endl;

                    close(sock);
                    break;
                }
                else
                {
                    std::cerr << "Read Fail: "
                              << strerror(errno)
                              << std::endl;

                    close(sock);
                    break;
                }
            }
        }

    private:
        int listen_sock_;
        std::string ip_;
        uint16_t port_;
        func_t func_;
    };
}

这一版最核心的地方就是:

cpp 复制代码
ThreadData* td = new ThreadData(service_sock, clientip, clientport, this);

pthread_t tid;
pthread_create(&tid, nullptr, Routine, td);

主线程得到连接以后,并不会自己执行:

cpp 复制代码
Service();

而是创建一个线程:

text 复制代码
service_sock
client IP
client Port
this

全部交给工作线程。

工作线程再执行:

cpp 复制代码
td->current_->Service(td->sock_, td->clientip_, td->clientport_);

所以:

text 复制代码
Main Thread
│
├── accept Client A
│      ↓
│   Thread A -> Service(A)
│
├── accept Client B
│      ↓
│   Thread B -> Service(B)
│
└── accept Client C
       ↓
    Thread C -> Service(C)

主线程始终能够继续:

cpp 复制代码
accept();

server.cc

业务层继续使用 Echo,不需要任何修改:

cpp 复制代码
#include <iostream>
#include <memory>
#include "server.hpp"

using namespace std;
using namespace fxs_server;

std::string Echo(const std::string& request)
{
    return request;
}

void Usage(const char* program)
{
    cout << "Usage:" << endl;
    cout << "\t" << program << " ServerPort" << endl;
}

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

    uint16_t port = stoi(argv[1]);

    unique_ptr<TcpServer> server(new TcpServer(Echo, port));

    server->InitServer();
    server->StartServer();

    return 0;
}

这里再次能够看到我们前面进行业务解耦的好处。

无论服务器使用:

text 复制代码
单进程
多进程
多线程

业务函数始终还是:

cpp 复制代码
std::string Echo(const std::string& request)
{
    return request;
}

变化的是:

服务器如何安排执行流去处理不同客户端。

业务本身没有发生变化。


Makefile

由于现在使用:

cpp 复制代码
pthread_create();
pthread_detach();

需要链接 pthread:

makefile 复制代码
.PHONY:all
all:server client

server:server.cc
	g++ -o $@ $^ -std=c++11 -lpthread

client:client.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -rf server client

客户端仍然使用前面已经完成的:

text 复制代码
client.hpp
client.cc

不需要修改。


4.5 运行多线程 TCP Server

重新编译:

bash 复制代码
make clean
make

启动 Server:

bash 复制代码
./server 8877

然后分别启动多个客户端。

Client A:

bash 复制代码
./client 127.0.0.1 8877

Client B:

bash 复制代码
./client 127.0.0.1 8877

Client C:

bash 复制代码
./client 127.0.0.1 8877

三个 Client 都可以持续发送消息:

text 复制代码
Client A
Please Enter >> hello A

Client B
Please Enter >> hello B

Client C
Please Enter >> hello C

并分别收到服务器 Echo。

此时服务器内部结构已经变成:

text 复制代码
Server Process
│
├── Main Thread
│      ↓
│    accept()
│
├── Thread A
│      ↓
│    Service(Client A)
│
├── Thread B
│      ↓
│    Service(Client B)
│
└── Thread C
       ↓
     Service(Client C)

这和多进程版本:

text 复制代码
Parent
├── Child A
├── Child B
└── Child C

解决的是同一个问题。

只不过现在所有执行流都属于:

同一个 Server 进程。


使用 ps 查看线程

可以打开另一个终端:

bash 复制代码
ps -aL | grep server

其中:

text 复制代码
-L

表示:

显示进程中的线程。

假设目前有三个客户端,那么可以看到同一个:

text 复制代码
PID

下面存在多个不同的:

text 复制代码
LWP

大致可以理解为:

text 复制代码
PID
↓
属于哪个进程

LWP
↓
具体哪个线程

所以你会看到:

text 复制代码
一个 Server PID
+
多个不同的线程 ID

这与前面的多进程版本形成非常明显的对比。

多进程:

text 复制代码
ps -ef
↓
多个不同 PID

多线程:

text 复制代码
ps -aL
↓
同一个 PID
+
多个线程

4.6 多进程和多线程到这里到底差在哪?

现在正好可以把两个版本放在一起。

多进程:

text 复制代码
Server Parent
│
├── Child A -> Client A
├── Child B -> Client B
└── Child C -> Client C

多线程:

text 复制代码
Server Process
│
├── Main Thread
├── Thread A -> Client A
├── Thread B -> Client B
└── Thread C -> Client C

它们的共同点:

text 复制代码
主执行流
↓
只负责accept

其他执行流
↓
负责Service

不同点:

text 复制代码
多进程
↓
不同进程拥有独立虚拟地址空间
↓
资源隔离更强
↓
创建和切换相对更重


多线程
↓
多个线程属于同一个进程
↓
共享地址空间、文件描述符等资源
↓
创建和切换相对更轻

但是多线程版本现在还有一个问题。

当前是:

text 复制代码
Client 来了
↓
pthread_create()

又来一个 Client
↓
再 pthread_create()

再来一个
↓
继续 pthread_create()

也就是说:

线程仍然是在客户端到来以后临时创建的。

如果客户端特别多:

text 复制代码
连接越多
↓
创建线程越多
↓
线程数量缺乏统一控制

那么有没有办法:

程序启动的时候就提前创建好固定数量的线程,客户端来了以后只需要把任务交给它们?

这就是我们前面已经学习过的:

线程池。

于是服务器继续演进:

text 复制代码
单进程
↓
多进程
↓
多线程
↓
线程池

5. 线程池版 TCP Server

现在多线程服务器还有一个问题。

当前逻辑:

text 复制代码
客户端来了
    ↓
accept
    ↓
pthread_create
    ↓
创建线程

也就是说:

客户端已经来了,我们才临时创建线程。

如果连接数量不断增加:

text 复制代码
来一个
↓
建一个线程

再来一个
↓
再建一个线程

......

线程数量就可能不断增长。

这并不是一个很好的资源管理方式。

于是我们自然想到前面已经学习过的:

线程池。


5.1 为什么使用线程池?

线程池:

text 复制代码
程序启动
↓
提前创建固定数量线程
↓
所有线程等待任务

客户端来了以后:

text 复制代码
accept()
↓
构造 Task
↓
Push 进入任务队列
↓
唤醒工作线程
↓
处理 Task

所以:

text 复制代码
连接到来

和:

text 复制代码
线程创建

不再绑定在一起。

整个结构:

text 复制代码
              accept
                ↓
            Task Queue
          ↙    ↓    ↘
      thread1 thread2 thread3

这本质上就是以前的:

生产者消费者模型。

主线程:

text 复制代码
生产任务

工作线程:

text 复制代码
消费任务

5.2 Task 到底是什么?

在当前 TCP Server 中,一份任务至少需要:

text 复制代码
service_sock
client IP
client Port
业务处理函数

所以:

cpp 复制代码
class Task
{
public:
    Task(int sockfd, const std::string& clientip, uint16_t clientport, const func_t& func)
        : sockfd_(sockfd), clientip_(clientip), clientport_(clientport), func_(func)
    {}

    void Run()
    {
        char buff[1024];

        ssize_t n = read(sockfd_, buff, sizeof(buff) - 1);

        if(n > 0)
        {
            buff[n] = '\0';

            std::string respond = func_(buff);

            write(sockfd_, respond.c_str(), respond.size());
        }

        close(sockfd_);
    }

private:
    int sockfd_;
    std::string clientip_;
    uint16_t clientport_;
    func_t func_;
};

然后:

cpp 复制代码
Task task(service_sock, clientip, clientport, func_);
ThreadPool<Task>::GetInstance()->Push(task);

5.3 为什么这里去掉 Service 的 while(true)?

这个地方必须讲明白。

如果线程池只有:

text 复制代码
5 个线程

而一个任务内部:

cpp 复制代码
while(true)
{
    read();
    write();
}

表示:

一个 Worker 一旦服务一个长连接,就一直被这个连接占住。

那么:

text 复制代码
5 个线程

最多就只能同时被:

text 复制代码
5 个长期连接

占用。

所以这个教学版本采用:

text 复制代码
一个连接
↓
读取一次
↓
处理一次
↓
返回一次
↓
关闭连接

短服务模型

因此如果 Client 仍然想在同一个 TCP 连接中发送第二条消息:

text 复制代码
第一次
↓
Server 处理
↓
Server close

第二次
↓
原连接已经关闭

当然会失败。

这不是:

线程池导致 TCP 不能连续通信。

更不是:

TCP 一个连接只能发送一次数据。

而只是:

我们这里的 Task::Run() 主动设计成了一次请求处理完就关闭连接。

如果业务需要:

text 复制代码
TCP 长连接

完全可以重新设计任务模型。

这里只是利用它理解:

text 复制代码
线程池
+
TCP Server

是如何结合的。


5.4 主线程加入线程池

核心代码:

cpp 复制代码
void StartServer()
{
    ThreadPool<Task>::GetInstance()->Start();

    while(true)
    {
        struct sockaddr_in client;
        socklen_t len = sizeof(client);

        int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);

        if(service_sock < 0)
            continue;

        uint16_t clientport = ntohs(client.sin_port);

        char clientip[32];
        inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));

        Task task(service_sock, clientip, clientport, func_);

        ThreadPool<Task>::GetInstance()->Push(task);
    }
}

于是:

text 复制代码
主线程
↓
只负责 accept + Push

线程池
↓
负责真正执行 Task::Run()

5.5 线程池版 TCP Server 完整代码

到这里,线程池版本需要的几个模块已经全部具备:

text 复制代码
主线程
↓
accept()
↓
获得一个新的 service_sock
↓
构造 Task
↓
Push 到任务队列
↓
继续 accept()


线程池中的工作线程
↓
提前创建
↓
等待任务
↓
从任务队列取出 Task
↓
Task::Run()
↓
处理客户端请求

和前面的多线程版本相比,最大的变化就是:

text 复制代码
多线程版

Client 到来
↓
pthread_create()
↓
临时创建工作线程

变成:

text 复制代码
线程池版

Server 启动
↓
提前创建固定数量工作线程
↓
Client 到来
↓
只需要构造 Task
↓
放入任务队列

这一阶段项目结构变成:

text 复制代码
TcpServer
├── ThreadPool.hpp
├── Task.hpp
├── server.hpp
├── server.cc
├── client.hpp
├── client.cc
└── Makefile

其中:

text 复制代码
client.hpp
client.cc

继续使用前面已经完成的 TCP Client,不需要修改。

这一阶段新增的是:

text 复制代码
ThreadPool.hpp
Task.hpp

并重新修改:

text 复制代码
server.hpp

ThreadPool.hpp

这里直接使用我们之前学习过的线程池。

线程池本质上仍然是一个:

生产者消费者模型。

text 复制代码
主线程
↓
生产 Task
↓
任务队列


工作线程
↓
消费 Task
↓
执行 Run()

代码如下:

cpp 复制代码
#pragma once

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

struct ThreadInfo
{
    pthread_t tid;
    std::string name;
};

static const int default_num = 5;

template<class T>
class ThreadPool
{
private:
    void Lock()
    {
        pthread_mutex_lock(&mutex_);
    }

    void Unlock()
    {
        pthread_mutex_unlock(&mutex_);
    }

    void Wakeup()
    {
        pthread_cond_signal(&cond_);
    }

    void ThreadSleep()
    {
        pthread_cond_wait(&cond_, &mutex_);
    }

    bool IsQueueEmpty()
    {
        return tasks_.empty();
    }

public:
    static void* HandlerTask(void* args)
    {
        ThreadPool<T>* tp = static_cast<ThreadPool<T>*>(args);

        while(true)
        {
            tp->Lock();

            while(tp->IsQueueEmpty())
                tp->ThreadSleep();

            T task = tp->Pop();

            tp->Unlock();

            task.Run();
        }

        return nullptr;
    }

    void Start()
    {
        int num = threads_.size();

        for(int i = 0; i < num; i++)
        {
            threads_[i].name = "thread-" + std::to_string(i + 1);
            pthread_create(&threads_[i].tid, nullptr, HandlerTask, this);
        }
    }

    T Pop()
    {
        T task = tasks_.front();
        tasks_.pop();

        return task;
    }

    void Push(const T& task)
    {
        Lock();

        tasks_.push(task);

        Wakeup();

        Unlock();
    }

    static ThreadPool<T>* GetInstance()
    {
        if(tp_ == nullptr)
        {
            pthread_mutex_lock(&lock_);

            if(tp_ == nullptr)
                tp_ = new ThreadPool<T>();

            pthread_mutex_unlock(&lock_);
        }

        return tp_;
    }

private:
    ThreadPool(int num = default_num)
        : threads_(num)
    {
        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&cond_, nullptr);
    }

    ~ThreadPool()
    {
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&cond_);
    }

    ThreadPool(const ThreadPool<T>&) = delete;
    ThreadPool<T>& operator=(const ThreadPool<T>&) = delete;

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 lock_;
};

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

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

线程池启动以后:

cpp 复制代码
ThreadPool<Task>::GetInstance()->Start();

就会提前创建:

text 复制代码
5 个 Worker

这些线程开始时没有任务,于是:

text 复制代码
Lock
↓
发现任务队列为空
↓
pthread_cond_wait
↓
线程休眠

主线程以后:

cpp 复制代码
Push(task);

就会:

text 复制代码
任务入队
↓
pthread_cond_signal
↓
唤醒一个 Worker
↓
取出 Task
↓
Task::Run()

Task.hpp

那么什么是:

text 复制代码
Task

呢?

前面的多线程版本把一个连接直接交给:

cpp 复制代码
Service()

处理。

现在使用线程池以后,不能再:

text 复制代码
accept
↓
创建线程
↓
Service

而是需要先把:

text 复制代码
这次需要做的事情

封装成一个对象。

一份 TCP 服务任务至少需要知道:

text 复制代码
service_sock
客户端 IP
客户端 Port
业务处理函数

因此:

cpp 复制代码
Task task(service_sock, clientip, clientport, func_);

本质上就是:

把一次网络服务所需要的所有信息打包。

Task.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cstring>
#include <cerrno>
#include <unistd.h>

namespace fxs_server
{
    using func_t = std::function<std::string(std::string)>;

    class Task
    {
    public:
        Task(int sockfd, const std::string& clientip, uint16_t clientport, const func_t& func)
            : sockfd_(sockfd), clientip_(clientip), clientport_(clientport), func_(func)
        {}

        ~Task()
        {}

        void Run()
        {
            char buff[1024];
            std::string who = clientip_ + ":" + std::to_string(clientport_);

            ssize_t n = read(sockfd_, buff, sizeof(buff) - 1);

            if(n > 0)
            {
                buff[n] = '\0';

                std::cout << "Server get: "
                          << buff
                          << " from "
                          << who
                          << std::endl;

                std::string respond = func_(buff);

                write(sockfd_, respond.c_str(), respond.size());
            }
            else if(n == 0)
            {
                std::cout << "Client "
                          << who
                          << " quit!"
                          << std::endl;
            }
            else
            {
                std::cerr << "Read Fail: "
                          << strerror(errno)
                          << std::endl;
            }

            close(sockfd_);
        }

    private:
        int sockfd_;
        std::string clientip_;
        uint16_t clientport_;
        func_t func_;
    };
}

仔细看:

cpp 复制代码
void Run()

其实就是前面:

cpp 复制代码
Service()

中的核心业务。

只不过它发生了一个非常重要的变化:

text 复制代码
以前:

while(true)
{
    read
    business
    write
}


现在:

read
↓
business
↓
write
↓
close
↓
Task结束

也就是把:

cpp 复制代码
while(true)

去掉了。


server.hpp

使用线程池以后,前面的:

text 复制代码
ThreadData
Routine
Service

都不再需要。

因为:

text 复制代码
ThreadData
↓
原来负责给临时创建的线程传参数

而现在:

text 复制代码
Task
↓
已经把任务需要的参数全部封装起来

所以 Server 只需要负责:

text 复制代码
accept
↓
构造 Task
↓
Push

完整代码:

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#include "ThreadPool.hpp"
#include "Task.hpp"

namespace fxs_server
{
    const uint16_t default_port = 8877;
    const std::string default_ip = "0.0.0.0";
    const int backlog = 5;

    enum
    {
        USAGE_ERR = 1,
        SOCKET_ERR,
        BIND_ERR,
        LISTEN_ERR
    };

    class TcpServer
    {
    public:
        TcpServer(const func_t& func, uint16_t port = default_port, const std::string& ip = default_ip)
            : ip_(ip), port_(port), func_(func)
        {}

        ~TcpServer()
        {}

        void InitServer()
        {
            // 1.创建监听套接字
            listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);

            if(listen_sock_ < 0)
            {
                std::cerr << "Create Socket Fail: "
                          << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create Socket Success: "
                      << listen_sock_
                      << std::endl;

            // 2.绑定IP和Port
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local));

            local.sin_family = AF_INET;
            local.sin_port = htons(port_);
            local.sin_addr.s_addr = inet_addr(ip_.c_str());

            if(bind(listen_sock_, (const struct sockaddr*)&local, sizeof(local)) < 0)
            {
                std::cerr << "Bind IP&&Port Fail: "
                          << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            std::cout << "Bind IP&&Port Success" << std::endl;

            // 3.进入监听状态
            if(listen(listen_sock_, backlog) < 0)
            {
                std::cerr << "Listen Fail: "
                          << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success" << std::endl;
        }

        void StartServer()
        {
            // 线程池只需要在服务器启动时创建一次
            ThreadPool<Task>::GetInstance()->Start();

            while(true)
            {
                // 1.获取新连接
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);

                if(service_sock < 0)
                {
                    std::cerr << "Accept Fail: "
                              << strerror(errno)
                              << std::endl;
                    continue;
                }

                // 2.获取客户端信息
                char clientip[32];
                inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));

                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Get a new connection -> "
                          << "[" << clientip << ":" << clientport << "] "
                          << "service_sock: " << service_sock
                          << std::endl;

                // 3.构造任务
                Task task(service_sock, clientip, clientport, func_);

                // 4.把任务交给线程池
                ThreadPool<Task>::GetInstance()->Push(task);
            }
        }

    private:
        int listen_sock_;
        std::string ip_;
        uint16_t port_;
        func_t func_;
    };
}

这里整个 Server 最核心的变化,其实只有:

cpp 复制代码
ThreadPool<Task>::GetInstance()->Start();

和:

cpp 复制代码
Task task(service_sock, clientip, clientport, func_);

ThreadPool<Task>::GetInstance()->Push(task);

最终运行流程:

text 复制代码
Server启动
↓
创建5个Worker
↓
Worker全部等待任务


Main Thread
↓
accept Client A
↓
Task A
↓
Push


Worker 1
↓
Pop Task A
↓
Run()


Main Thread
↓
继续accept Client B
↓
Task B
↓
Push


Worker 2
↓
Pop Task B
↓
Run()

这就是线程池版本最核心的结构。


server.cc

业务处理仍然完全不需要变化:

cpp 复制代码
#include <iostream>
#include <memory>
#include "server.hpp"

using namespace std;
using namespace fxs_server;

std::string Echo(const std::string& request)
{
    return request;
}

void Usage(const char* program)
{
    cout << "Usage:" << endl;
    cout << "\t" << program << " ServerPort" << endl;
}

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

    uint16_t port = stoi(argv[1]);

    unique_ptr<TcpServer> server(new TcpServer(Echo, port));

    server->InitServer();
    server->StartServer();

    return 0;
}

到这里其实已经连续验证了很多次业务解耦的意义。

从:

text 复制代码
单进程
↓
多进程
↓
多线程
↓
线程池

服务器内部的调度方式一直在改变。

但:

cpp 复制代码
std::string Echo(const std::string& request)
{
    return request;
}

始终不需要改变。

也就是说:

text 复制代码
网络通信 / 并发模型

和:

text 复制代码
具体业务

已经基本分开了。


5.6 运行线程池版 TCP Server

重新编译:

bash 复制代码
make clean
make

启动 Server:

bash 复制代码
./server 8877

这时候一个 Client 都还没有连接。

马上执行:

bash 复制代码
ps -aL | grep server

就应该能够看到:

一共:

text 复制代码
6 个线程

其中:

text 复制代码
1 个主线程
+
5 个线程池 Worker

注意:

此时还没有任何客户端连接。

这正是线程池和前一个多线程版本最直观的区别。

前一个版本:

text 复制代码
Server刚启动
↓
只有主线程

Client A来了
↓
多一个线程

Client B来了
↓
再多一个线程

而线程池:

text 复制代码
Server刚启动
↓
主线程 + 5个Worker已经全部存在

再连接客户端

启动:

bash 复制代码
./client 127.0.0.1 8877

输入:

text 复制代码
hello thread pool

流程:

text 复制代码
Client
↓
connect


Server Main Thread
↓
accept
↓
Task
↓
Push


Worker
↓
Pop
↓
read
↓
Echo
↓
write
↓
close

客户端能够正常收到:

text 复制代码
hello thread pool

但是如果继续在原来的连接中发送第二条消息,就会发现:

text 复制代码
连接已经断开

这是正常的。

因为当前:

cpp 复制代码
Task::Run()

在处理完一次请求以后执行:

cpp 复制代码
close(sockfd_);

也就是说我们当前设计的是:

text 复制代码
一次连接
↓
一次请求
↓
一次响应
↓
关闭

而不是:

text 复制代码
建立一次连接
↓
while(true)
↓
长期通信

一定不要把这个现象理解成:

线程池不能处理 TCP 长连接。

它只能说明:

我们当前这个 Task 被人为设计成短任务。


5.7 三个版本放在一起看

现在正好可以把三个并发版本彻底串起来。

多进程
text 复制代码
Client来了
↓
fork()
↓
Child
↓
Service

结构:

text 复制代码
Parent
├── Child A
├── Child B
└── Child C

多线程
text 复制代码
Client来了
↓
pthread_create()
↓
Thread
↓
Service

结构:

text 复制代码
Server Process
├── Main Thread
├── Thread A
├── Thread B
└── Thread C

线程池
text 复制代码
Server启动
↓
提前创建Worker


Client来了
↓
Task
↓
Queue
↓
已有Worker处理

结构:

text 复制代码
Server Process
│
├── Main Thread
│      ↓
│    accept
│      ↓
│    Push
│
├── Worker 1 ─┐
├── Worker 2  │
├── Worker 3  ├── Task Queue
├── Worker 4  │
└── Worker 5 ─┘

所以整个服务器并发模型的演进其实非常自然:

text 复制代码
单进程
↓
Service占住唯一执行流
↓
多进程
↓
能并发,但进程较重
↓
多线程
↓
更轻,但每来一个连接临时创建线程
↓
线程池
↓
提前创建固定数量线程
↓
连接来了只提交Task

到这里,我们已经从:

text 复制代码
能通信

一步一步走到了:

text 复制代码
如何更合理地组织服务器并发执行

下一步再继续解决:

怎样让这个 Server 脱离终端,长期运行在后台?

这就进入守护进程。

6. 把 TCP Server 变成守护进程

到目前为止,我们的 TCP Server 已经经历了:

text 复制代码
单进程
    ↓
多进程
    ↓
多线程
    ↓
线程池

服务器已经具备了一定的并发处理能力。

但现在还有一个问题。

我们启动 Server 时使用:

bash 复制代码
./server 8877

程序会一直占着当前终端运行。

例如:

text 复制代码
终端
  │
  └── ./server 8877
          ↓
       长期运行

而真正的服务器程序往往需要:

text 复制代码
长期运行
+
不占用终端
+
不依赖当前终端是否一直存在

例如:

text 复制代码
sshd
crond

这种程序,并不需要我们始终打开一个终端看着它们。

这就引出了:

守护进程(Daemon)。

守护进程可以简单理解为:

脱离控制终端、长期在后台运行并提供某种服务的进程。

我们的 TCP Server 本身就是一个非常典型的适合守护进程化的程序。


6.1 先看看现在的 Server 到底和终端有什么关系

先暂时不要做任何守护进程化。

正常启动:

bash 复制代码
./server 8877

然后再打开一个终端执行:

bash 复制代码
ps axj | head -1

看到

这里突然出现了一堆以前不太关注的字段:

text 复制代码
PID
PPID
PGID
SID
TPGID
TTY

其中:

text 复制代码
PID
↓
当前进程是谁

PPID
↓
当前进程的父进程是谁

PGID
↓
当前进程属于哪个进程组

SID
↓
当前进程属于哪个会话

TPGID
↓
当前终端的前台进程组

TTY
↓
当前进程关联的终端

例如:

text 复制代码
TTY = pts/5

就说明:

当前 Server 仍然处在 pts/5 这个终端的控制体系中。

所以如果想真正理解守护进程,我们首先要把:

text 复制代码
进程
进程组
作业
会话
控制终端

这几个概念串起来。


6.2 从一个终端理解进程组、作业和会话

这些概念如果分开背会非常乱。

直接记住下面这条关系:

text 复制代码
一个终端
    ↓
对应一套会话 Session
    ↓
Session 中可以存在多个进程组 Process Group
    ↓
Shell 利用进程组进行作业控制
    ↓
其中最多有一个前台进程组

也就是说,可以粗略理解为:

text 复制代码
Session
│
├── Foreground Process Group
│       ├── Process
│       └── Process
│
├── Background Process Group
│       └── Process
│
└── Background Process Group
        ├── Process
        └── Process

接下来分别看看它们到底是什么。


6.2.1 进程组

进程除了有自己的:

text 复制代码
PID

之外,还会属于一个:

text 复制代码
Process Group

也就是:

进程组。

每个进程组都有:

text 复制代码
PGID

一个进程组中可以包含多个相关进程。

例如我们前面写多进程 TCP Server 时:

text 复制代码
Server父进程
├── Server子进程A
├── Server子进程B
└── Server子进程C

使用 ps 查看时就可能发现:

text 复制代码
PID 不同
PGID 相同

也就是说:

text 复制代码
它们是不同进程
↓
但属于同一个进程组

所以:

text 复制代码
PID
↓
标识单独一个进程

PGID
↓
标识一组相关进程

为什么操作系统还需要把进程组成一个组?

其中一个非常重要的原因,就是:

方便对一组相关进程统一进行控制。

例如终端产生某些信号时,可以直接发送给:

text 复制代码
一个前台进程组

而不是只发送给某一个进程。


6.2.2 作业

再看 Shell 中一个非常常见的现象。

执行:

bash 复制代码
sleep 100

此时终端会被它占用。

sleep 结束之前,Shell 不会重新给我们命令提示符。

这种就可以看作:

前台作业。

而如果:

bash 复制代码
sleep 100 &

Shell 会立即返回:

text 复制代码
$

此时:

text 复制代码
sleep 100

仍然在运行,但是已经成为:

后台作业。

所以 Shell 中:

text 复制代码
前台
后台
jobs
fg
bg

这些功能,本质上都属于:

作业控制(Job Control)。

Shell 在实现作业控制时,会大量使用:

text 复制代码
进程组

这个操作系统机制。

因此:

text 复制代码
作业

更多是:

Shell 看待和管理用户任务的角度。

而:

text 复制代码
进程组

则是:

操作系统真正用来组织进程的机制。


6.2.3 会话

比进程组范围更大的概念叫:

text 复制代码
Session

也就是:

会话。

一个会话中可以包含多个:

text 复制代码
Process Group

所以层级关系:

text 复制代码
Session
    ↓
Process Group
    ↓
Process

例如:

text 复制代码
Session
│
├── Process Group A
│      ├── Process 1
│      └── Process 2
│
├── Process Group B
│      └── Process 3
│
└── Process Group C
       ├── Process 4
       └── Process 5

一个会话中:

text 复制代码
最多有一个前台进程组
+
可以有多个后台进程组

而一个会话还可能拥有:

控制终端。

这就把最后一个概念串起来了。


6.3 控制终端到底控制了什么?

我们平时看终端,容易觉得它只是一个:

text 复制代码
输入命令
+
显示输出

的窗口。

实际上终端还参与了:

进程控制。

最典型的例子就是:

text 复制代码
Ctrl + C

假设当前运行:

bash 复制代码
./server 8877

然后按:

text 复制代码
Ctrl + C

Server 会退出。

这里并不是:

Shell 发现你按了 Ctrl+C,然后自己调用 kill(server_pid)

更接近真实的过程是:

text 复制代码
Ctrl + C
    ↓
终端驱动识别特殊字符
    ↓
产生 SIGINT
    ↓
发送给当前终端的前台进程组

所以:

text 复制代码
控制终端

不仅负责输入输出,还参与:

text 复制代码
前台进程组
+
作业控制
+
终端产生的信号

例如:

text 复制代码
Ctrl+C
↓
SIGINT

Ctrl+Z
↓
SIGTSTP

都会和:

text 复制代码
当前前台进程组

发生关系。

现在再回头看 Server:

text 复制代码
./server 8877
↓
属于当前Session
↓
属于某个进程组
↓
仍然关联当前控制终端

所以它还没有真正成为一个:

text 复制代码
独立运行的后台服务

6.4 后台进程就是守护进程吗?

既然我们只是嫌 Server 占着终端,那能不能直接:

bash 复制代码
./server 8877 &

不就行了吗?

这样确实会立即返回 Shell。

但是:

后台进程并不等于守护进程。

执行:

bash 复制代码
./server 8877 &

只是让 Server 作为:

text 复制代码
Shell 的后台作业

运行。

它通常仍然处在:

text 复制代码
原来的 Session
+
原来的终端体系

之中。

可以执行:

bash 复制代码
ps -o pid,ppid,pgid,sid,tpgid,tty,stat,cmd -C server

继续观察。

通常仍然能够看到类似:

text 复制代码
TTY = pts/5
SID = 当前终端对应的Session

也就是说:

text 复制代码
./server &

只是:

text 复制代码
不要占据Shell前台

并没有真正完成:

text 复制代码
脱离原会话
脱离控制终端

所以真正的守护进程需要继续做更多工作。


6.5 setsid:真正脱离原来的会话

Linux 提供:

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

pid_t setsid(void);

调用:

cpp 复制代码
setsid();

成功以后,当前进程会:

text 复制代码
创建一个新的 Session
        ↓
自己成为 Session Leader
        ↓
同时创建一个新的 Process Group
        ↓
自己成为 Process Group Leader
        ↓
脱离原来的控制终端

这一步正是:

守护进程化最核心的操作。

如果我们原来是:

text 复制代码
原Session
│
├── Shell
└── Server
       ↓
     pts/5

调用:

cpp 复制代码
setsid();

成功以后:

text 复制代码
原Session
│
└── Shell
       ↓
     pts/5


新Session
│
└── Server
      ↓
   无控制终端

这样 Server 才真正从原来的终端控制体系中脱离出来。


6.6 为什么不能直接调用 setsid?

这里还有一个限制:

如果当前进程已经是进程组组长,那么 setsid() 会失败。

所以经典守护进程的第一步并不是:

cpp 复制代码
setsid();

而是:

cpp 复制代码
fork();

整体过程:

text 复制代码
当前进程
    ↓
fork()
    ↓
┌──────────────┐
│              │
父进程         子进程
│              │
退出           继续
               ↓
            setsid()

为什么新创建的子进程适合调用:

cpp 复制代码
setsid();

呢?

因为子进程:

text 复制代码
PID
↓
是一个新PID

但是它会继承父进程原来的:

text 复制代码
PGID

所以通常:

text 复制代码
子进程 PID != PGID

也就是说:

这个新子进程不是原进程组的组长。

于是可以成功:

cpp 复制代码
setsid();

因此第一次 fork() 的一个非常重要的目的就是:

得到一个肯定不是原进程组组长的子进程,从而安全调用 setsid()


6.7 为什么经典守护进程还要再 fork 一次?

现在:

text 复制代码
第一次fork
↓
父进程退出
↓
子进程setsid()

当前子进程已经:

text 复制代码
脱离原控制终端
+
成为新Session Leader

似乎已经够了。

但经典的守护进程实现通常还会:

cpp 复制代码
fork();

第二次。

原因在于:

调用 setsid() 的进程已经成为了 Session Leader。

一个 Session Leader 在特定条件下仍存在:

text 复制代码
重新获得控制终端

的可能。

所以再:

text 复制代码
Session Leader
    ↓
fork()
    ↓
┌────────────────┐
│                │
父进程           子进程
Session Leader    最终Daemon
│                │
退出             继续运行

最终真正提供服务的进程:

text 复制代码
不是 Session Leader

因此进一步避免它以后重新获得控制终端。

所以经典双重 fork() 的核心逻辑:

text 复制代码
第一次fork
↓
为了能够setsid


setsid
↓
创建新会话并脱离控制终端


第二次fork
↓
让最终Daemon不再是Session Leader
↓
避免重新获得控制终端

需要特别注意:

第二次 fork() 的主要目的不是解决僵尸进程问题。

它解决的是:

Session Leader 仍可能重新获得控制终端的问题。


6.8 守护进程还需要处理什么?

完成:

text 复制代码
fork
↓
setsid
↓
fork

以后,控制终端的问题基本解决了。

但是还有几个运行环境需要处理。


6.8.1 修改当前工作目录

假设我们在:

text 复制代码
/home/lenovo/code/TcpServer

启动 Server。

当前进程的:

text 复制代码
current working directory

也是:

text 复制代码
/home/lenovo/code/TcpServer

守护进程可能长期运行几小时、几天,甚至更久。

经典守护进程通常会:

cpp 复制代码
chdir("/");

把工作目录切换到根目录。

这样可以避免守护进程长期占用某个特定目录所在的文件系统。

text 复制代码
Daemon
↓
chdir("/")
↓
不再依赖启动时所在目录

不过这里也要注意:

如果程序以后需要通过相对路径访问配置文件、网页资源等内容,就不能随便 chdir("/")

例如以后 HTTP Server 使用:

text 复制代码
./wwwroot

作为网站根目录,那么突然:

cpp 复制代码
chdir("/");

就会导致:

text 复制代码
./wwwroot

的含义发生变化。

因此生产程序应该根据自己的实际需求选择:

text 复制代码
/

或者:

text 复制代码
某个固定工作目录

当前 TCP Echo Server 不依赖相对路径,因此使用:

cpp 复制代码
chdir("/");

没有问题。


6.8.2 修改 umask

进程会继承父进程的:

text 复制代码
umask

而守护进程以后可能会创建:

text 复制代码
日志文件
配置文件
PID文件
临时文件

为了避免启动它的 Shell 环境影响文件权限,可以重新设置:

cpp 复制代码
umask(0);

这样守护进程后续创建文件时,就不会额外继承原进程的权限屏蔽规则。

当前只是为了学习经典守护进程流程。

实际项目中也可以根据安全需求设置一个明确的:

text 复制代码
umask

而不是一定使用 0


6.9 stdin、stdout、stderr 怎么办?

这里还有一个非常关键的问题。

我们当前 Server 中大量使用:

cpp 复制代码
std::cout
std::cerr

本质上最终都会使用:

text 复制代码
stdout
stderr

也就是:

text 复制代码
fd 1
fd 2

正常前台程序:

text 复制代码
fd 0 -> 当前终端
fd 1 -> 当前终端
fd 2 -> 当前终端

但是现在我们不是已经说:

守护进程要脱离终端吗?

所以:

text 复制代码
0
1
2

就不应该继续依赖原来的终端。

经典做法之一就是:

全部重定向到 /dev/null


/dev/null 是什么?

Linux 中:

text 复制代码
/dev/null

是一个特殊设备文件。

可以简单理解成:

数据黑洞。

向它写数据:

bash 复制代码
echo "hello" > /dev/null

数据会直接被丢弃。

读取:

text 复制代码
/dev/null

则会立即得到 EOF。

因此对于守护进程:

text 复制代码
stdin
stdout
stderr

暂时不需要时,可以全部重定向到:

text 复制代码
/dev/null

使用 dup2 完成重定向

先打开:

cpp 复制代码
int fd = open("/dev/null", O_RDWR);

然后:

cpp 复制代码
dup2(fd, STDIN_FILENO);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);

最终:

text 复制代码
fd 0 ─┐
fd 1 ─┼──> /dev/null
fd 2 ─┘

这样:

cpp 复制代码
std::cout << "hello";

虽然代码仍然执行,但内容最终进入:

text 复制代码
/dev/null

直接被丢弃。

所以一旦 Server 真正守护进程化以后:

原来终端里的 cout/cerr 日志看不到是正常现象。

真正的服务器以后应该使用:

text 复制代码
日志文件
syslog
专业日志系统

记录运行信息。


6.10 手动实现完整守护进程

到这里,创建守护进程的所有步骤就都能解释清楚了。

完整流程:

text 复制代码
当前进程
    ↓
fork()
    ↓
父进程退出
    ↓
子进程继续
    ↓
setsid()
    ↓
创建新Session
    ↓
脱离控制终端
    ↓
再次fork()
    ↓
Session Leader退出
    ↓
最终Daemon不是Session Leader
    ↓
umask()
    ↓
chdir()
    ↓
打开/dev/null
    ↓
dup2()
    ↓
0/1/2全部脱离终端

现在就把它真正写出来。

新增:

text 复制代码
Daemon.hpp

当前项目:

text 复制代码
TcpServer
├── Daemon.hpp
├── ThreadPool.hpp
├── Task.hpp
├── server.hpp
├── server.cc
├── client.hpp
├── client.cc
└── Makefile

6.10.1 Daemon.hpp

cpp 复制代码
#pragma once

#include <cstdlib>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>

namespace fxs_daemon
{
    enum
    {
        FORK_ERR = 1,
        SETSID_ERR,
        CHDIR_ERR,
        OPEN_ERR,
        DUP_ERR
    };

    void Daemon()
    {
        // 1.第一次fork
        pid_t id = fork();

        if(id < 0)
            exit(FORK_ERR);

        if(id > 0)
            _exit(0);

        // 2.创建新的Session,脱离原控制终端
        if(setsid() < 0)
            exit(SETSID_ERR);

        // 3.第二次fork,让最终进程不再是Session Leader
        id = fork();

        if(id < 0)
            exit(FORK_ERR);

        if(id > 0)
            _exit(0);

        // 4.重新设置文件权限掩码
        umask(0);

        // 5.修改当前工作目录
        if(chdir("/") < 0)
            exit(CHDIR_ERR);

        // 6.打开/dev/null
        int fd = open("/dev/null", O_RDWR);

        if(fd < 0)
            exit(OPEN_ERR);

        // 7.重定向标准输入、标准输出、标准错误
        if(dup2(fd, STDIN_FILENO) < 0 ||
           dup2(fd, STDOUT_FILENO) < 0 ||
           dup2(fd, STDERR_FILENO) < 0)
        {
            exit(DUP_ERR);
        }

        if(fd > STDERR_FILENO)
            close(fd);
    }
}

这里使用:

cpp 复制代码
_exit(0);

而不是普通:

cpp 复制代码
exit(0);

是因为:

fork() 以后父子进程会继承用户层的一些缓冲状态。

_exit() 会直接结束当前进程,不再重复处理用户层缓冲区和退出处理函数。

在这种:

text 复制代码
fork后父进程立即退出

的场景下更加直接。


6.10.2 接入当前 TCP Server

现在服务器已经是线程池版本。

其他代码:

text 复制代码
ThreadPool.hpp
Task.hpp
server.hpp
client.hpp
client.cc

全部不用修改。

只需要修改:

text 复制代码
server.cc

引入:

cpp 复制代码
#include "Daemon.hpp"

然后在真正创建服务器之前:

cpp 复制代码
Daemon();

完整:

cpp 复制代码
#include <iostream>
#include <memory>
#include "server.hpp"
#include "Daemon.hpp"

using namespace std;
using namespace fxs_server;
using namespace fxs_daemon;

std::string Echo(const std::string& request)
{
    return request;
}

void Usage(const char* program)
{
    cout << "Usage:" << endl;
    cout << "\t" << program << " ServerPort" << endl;
}

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

    uint16_t port = stoi(argv[1]);

    // 将当前进程守护进程化
    Daemon();

    unique_ptr<TcpServer> server(new TcpServer(Echo, port));

    server->InitServer();
    server->StartServer();

    return 0;
}

这里一定要注意调用顺序:

text 复制代码
解析参数
↓
Daemon()
↓
InitServer()
↓
StartServer()

我们是在:

cpp 复制代码
socket();

之前完成守护进程化。

因此当前:

text 复制代码
listen_sock
service_sock

都还没有创建。

这样就不会出现守护进程化过程中误处理 Server Socket 的问题。


6.11 真正运行并观察守护进程

现在重新:

bash 复制代码
make clean
make

启动:

bash 复制代码
./server 8877

会发现:

命令几乎立即返回了。

例如:

text 复制代码
lenovo@LAPTOP:~/code/TcpServer$ ./server 8877
lenovo@LAPTOP:~/code/TcpServer$

但是:

Server 并没有退出。

执行:

bash 复制代码
ps -o pid,ppid,pgid,sid,tpgid,tty,stat,cmd -C server

应该能够看到 Server 仍然存在。

最值得观察的是:

text 复制代码
TTY

守护进程化之前:

text 复制代码
TTY
↓
pts/5

守护进程化以后通常:

text 复制代码
TTY
↓
?

也就是说:

text 复制代码
之前
Server -> pts/5


之后
Server -> 无控制终端

这才是:

守护进程真正应该观察的现象。


再看 PID、PGID 和 SID

双重 fork() 后,最终运行 Server 的进程:

text 复制代码
已经不是原来的那个进程

并且:

text 复制代码
最终Daemon
↓
不是Session Leader

所以最终可能观察到:

text 复制代码
PID != SID

甚至:

text 复制代码
PID != PGID

这正是第二次:

cpp 复制代码
fork();

留下来的结果。

原来调用:

cpp 复制代码
setsid();

的那个进程:

text 复制代码
PID = SID = PGID

但是它随后:

text 复制代码
第二次fork
↓
自己退出

最终留下的子进程继承:

text 复制代码
Session
+
Process Group

但它自己的:

text 复制代码
PID

已经变成新的值。

因此最终 Daemon:

text 复制代码
属于这个新Session
↓
但自己不是Session Leader

这正是我们想要的效果。


6.12 脱离终端以后还能正常提供 TCP 服务吗?

当然可以。

守护进程化改变的是:

text 复制代码
Server 如何运行

而不是:

text 复制代码
TCP 如何通信

所以 Client 仍然正常:

bash 复制代码
./client 127.0.0.1 8877

发送:

text 复制代码
hello daemon

服务器仍然:

text 复制代码
accept
↓
Task
↓
ThreadPool
↓
Echo
↓
返回Client

客户端仍然能够收到:

text 复制代码
hello daemon

因此:

text 复制代码
Daemon

和:

text 复制代码
TCP

并不存在什么新的网络关系。

它只是把:

text 复制代码
前台TCP Server

变成:

text 复制代码
后台长期运行的TCP Server

6.13 Linux 已经提供了 daemon()

手动写完以后,我们已经真正知道:

text 复制代码
守护进程为什么要fork
为什么需要setsid
为什么再fork
为什么处理工作目录
为什么处理umask
为什么处理0、1、2

实际上 Linux/glibc 已经提供了一个非常方便的函数:

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

int daemon(int nochdir, int noclose);

使用:

cpp 复制代码
daemon(0, 0);

其中:

text 复制代码
nochdir

控制:

text 复制代码
是否改变工作目录

当:

cpp 复制代码
nochdir == 0

时,会切换工作目录。

第二个参数:

text 复制代码
noclose

控制:

text 复制代码
是否处理stdin/stdout/stderr

当:

cpp 复制代码
noclose == 0

时,会将标准输入、标准输出、标准错误处理到:

text 复制代码
/dev/null

因此:

cpp 复制代码
daemon(0, 0);

就可以帮助我们完成常见的守护进程化操作。

需要注意:

glibc 的 daemon() 并不是把我们上面的"双重 fork"代码一字不差地封装起来。

它完成的是常见的:

text 复制代码
fork
↓
setsid
↓
chdir
↓
处理0、1、2

但 glibc 的实现不会再进行我们前面经典流程中的第二次 fork()

因此调用成功后产生的进程仍可能是:

text 复制代码
Session Leader

对于我们当前学习 TCP Server 来说:

cpp 复制代码
daemon(0, 0);

已经足够方便地完成实验。

但手写经典双重 fork() 仍然有意义,因为它能让我们真正理解:

守护进程到底是怎样从原来的终端环境中脱离出来的。


6.14 使用 daemon() 简化 Server

如果不想自己维护:

text 复制代码
Daemon.hpp

那么:

text 复制代码
server.cc

甚至可以直接写成:

cpp 复制代码
#include <iostream>
#include <memory>
#include <unistd.h>
#include "server.hpp"

using namespace std;
using namespace fxs_server;

std::string Echo(const std::string& request)
{
    return request;
}

void Usage(const char* program)
{
    cout << "Usage:" << endl;
    cout << "\t" << program << " ServerPort" << endl;
}

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

    uint16_t port = stoi(argv[1]);

    if(daemon(0, 0) == -1)
        return 1;

    unique_ptr<TcpServer> server(new TcpServer(Echo, port));

    server->InitServer();
    server->StartServer();

    return 0;
}

也就是把前面一整套守护进程化过程,在当前 Linux 环境下简化成:

cpp 复制代码
daemon(0, 0);

但是现在我们再看到这一行时,已经不会只是:

"背一个 daemon API。"

而是能够知道它背后解决的到底是什么问题:

text 复制代码
普通Server
↓
属于当前Session
↓
关联控制终端
↓
终端会参与作业控制


守护进程化
↓
创建独立Session
↓
脱离原控制终端
↓
处理运行目录
↓
处理文件权限掩码
↓
处理stdin/stdout/stderr
↓
长期后台运行

6.15 这一节到底要记什么?

守护进程这一块概念很多,但真正复习的时候不需要把所有名词拆开背。

只需要顺着这一条因果链:

text 复制代码
我的TCP Server为什么要守护进程化?
↓
因为Server应该长期运行,不应该依赖一个终端一直存在

为什么普通 ./server & 不够?
↓
因为它只是后台作业,仍然处于原来的Session和终端体系中

为什么终端还能控制进程?
↓
因为进程属于进程组
↓
进程组属于Session
↓
Session可能关联控制终端
↓
终端通过前台进程组进行作业控制

怎么真正脱离原终端?
↓
setsid()

为什么setsid前先fork?
↓
进程组组长不能成功setsid
↓
fork出的子进程不是原进程组组长

setsid之后发生什么?
↓
创建新Session
↓
成为Session Leader
↓
创建新Process Group
↓
成为Process Group Leader
↓
脱离原控制终端

为什么还要第二次fork?
↓
让最终Daemon不再是Session Leader
↓
避免以后重新获得控制终端

然后还要处理什么?
↓
umask
chdir
stdin/stdout/stderr

stdin/stdout/stderr放哪?
↓
/dev/null

最终怎么验证?
↓
ps查看
↓
TTY从pts/x变成?
↓
Client仍然可以正常访问Server

如果这条能够顺着讲下来:

进程组、作业、会话、控制终端、setsid、双重 fork 和守护进程就不再是七八个互不相干的知识点,而是一整套因果关系。

到这里,我们才算真正把:

text 复制代码
TCP Server

从一个普通前台程序,变成了一个更符合服务器运行形态的:

text 复制代码
后台服务程序

接下来再回到 TCP 本身,从内核角度重新理解:

text 复制代码
connect
accept
三次握手
四次挥手
Socket缓冲区
全双工
字节流

这些接口背后到底发生了什么。


7. 再从底层重新理解 TCP

程序写到这里,我们已经会用了。

接下来把刚才写代码时看到的现象重新串起来。

这一部分比代码本身更重要。


7.1 connect、accept 和三次握手到底是什么关系?

应用层:

text 复制代码
Client
↓
connect()

Server
↓
accept()

很容易让人产生错觉:

text 复制代码
connect
+
accept
=
三次握手

实际上不是。

真正的 TCP 连接建立发生在:

客户端和服务器操作系统中的 TCP 协议栈。

可以理解成:

text 复制代码
Client Application
      │
   connect()
      ↓
Client TCP
      │
      │ 三次握手
      ↓
Server TCP
      │
   已建立连接
      ↓
完成连接队列
      │
   accept()
      ↓
Server Application

所以:

cpp 复制代码
connect();

是:

应用层告诉内核:我要主动连接这个 Server。

cpp 复制代码
accept();

是:

Server 应用从内核已经建立完成的连接中拿走一个结果。

因此:

accept 不参与三次握手的具体过程。

即使应用暂时没有:

cpp 复制代码
accept();

只要队列还有空间,TCP 三次握手仍然可能完成。

这也是为什么前面的:

text 复制代码
Client B

能够显示:

text 复制代码
connect 成功

但 Server 程序仍然卡在:

text 复制代码
Service(Client A)

7.2 TCP 连接需要成本吗?

当然需要。

服务器同时维护:

text 复制代码
Client A
Client B
Client C
......

操作系统不能只记一句:

"我和 A 连着呢。"

它至少必须维护大量 TCP 状态:

text 复制代码
对端 IP
对端 Port
本端 IP
本端 Port
TCP 状态
序号信息
确认信息
发送相关状态
接收相关状态
缓冲区
......

因此内核需要:

text 复制代码
先描述连接
↓
形成内核对象
↓
再用数据结构组织这些对象

所以:

TCP 连接本身是有时间和空间成本的。

这也是为什么实际服务器不能无限维护连接。


7.3 简单理解三次握手

Client 主动连接:

text 复制代码
Client                         Server

  SYN
   ───────────────────────────>

                SYN + ACK
   <───────────────────────────

  ACK
   ───────────────────────────>

       TCP Connection Established

这个过程由:

text 复制代码
两端 TCP 协议栈

负责。

应用层主要看到的现象就是:

text 复制代码
connect 成功
accept 返回

具体三次握手为什么一定是三次、序号如何交换、为什么能够避免历史连接等问题,后面的 TCP 协议进阶再详细展开。

这里暂时只建立:

text 复制代码
TCP 通信之前
↓
先建立连接
↓
建立连接依赖三次握手

这条主线。


7.4 简单理解四次挥手

假设 Client 主动关闭:

text 复制代码
Client                         Server

  FIN
   ───────────────────────────>

                  ACK
   <───────────────────────────

                  FIN
   <───────────────────────────

  ACK
   ───────────────────────────>

应用层看到:

cpp 复制代码
close(client_sock);

会触发 TCP 关闭过程。

Server:

cpp 复制代码
read();

最终读取到:

text 复制代码
0

就能够知道:

对端这一方向已经关闭。

Server 随后也:

cpp 复制代码
close();

触发自己这一侧的关闭流程。

所以:

text 复制代码
close()
read() == 0

都是应用层看到的接口现象。

真正的:

text 复制代码
FIN
ACK
TCP 状态转换

仍然由内核 TCP 协议栈完成。


7.5 TCP 通信的本质:先拷到内核缓冲区

假设应用代码:

cpp 复制代码
char buff[1024];

write(sock, buff, n);

千万不要理解成:

write() 一调用,CPU 就立刻把这些数据直接发送到另一台电脑。

更准确地说:

text 复制代码
用户层 buff
    ↓
write()
    ↓
内核 Socket 发送缓冲区

随后:

TCP 协议栈什么时候真正发送、怎么分段、什么时候重传,由内核自己决定。

接收也是:

text 复制代码
网络数据
    ↓
TCP
    ↓
Socket 接收缓冲区
    ↓
read()
    ↓
应用层 buff

所以从应用层角度:

cpp 复制代码
write();

首先做的是:

把用户层数据交给内核。

cpp 复制代码
read();

首先做的是:

把内核已经收到的数据拷贝到用户缓冲区。


7.6 为什么 TCP 是全双工?

每条 TCP 连接都能够:

text 复制代码
Client -> Server

同时:

text 复制代码
Server -> Client

双方互不排斥。

可以粗略理解成两端都存在自己的:

text 复制代码
发送方向
+
接收方向

例如:

text 复制代码
Client 发送缓冲区
        ↓
Server 接收缓冲区


Server 发送缓冲区
        ↓
Client 接收缓冲区

所以:

Client 正在给 Server 发数据,并不妨碍 Server 同时给 Client 发数据。

这就是 TCP:

text 复制代码
全双工

的重要体现。


7.7 TCP 最大的问题:它是字节流,不是消息流

我们前面的 Echo 程序还有一个非常重要的问题。

Client:

cpp 复制代码
write(sock, "hello", 5);

Server:

cpp 复制代码
read(sock, buff, sizeof(buff));

我们当前代码默认:

text 复制代码
一次 write
↓
对应一次 read

但 TCP 根本没有这种保证。

TCP 只提供:

连续、有序、可靠的字节流。

例如客户端:

text 复制代码
write("hello")
write("world")

Server 有可能:

text 复制代码
第一次 read
↓
helloworld

也可能:

text 复制代码
第一次
↓
hel

第二次
↓
loworld

甚至还可能有其他组合。

所以:

text 复制代码
发送了几次

和:

text 复制代码
对方 read 了几次

没有一一对应关系。

这和管道非常类似:

text 复制代码
写端
↓
连续写入字节流

读端
↓
根据当前缓冲区状态读取

因此真实 TCP 应用一定要解决:

怎么判断一条完整消息到底从哪里开始、在哪里结束?

答案就是:

应用层协议。

例如:

text 复制代码
固定长度

或者:

text 复制代码
特殊分隔符

或者:

text 复制代码
长度字段 + 正文

这些将在后面的协议设计中继续解决。

所以一定记住:

text 复制代码
TCP 保证字节可靠有序
≠
TCP 帮你保存应用层消息边界

7.8 网络字节序不要和应用数据混在一起

这里还要纠正一个特别容易混淆的问题。

像:

cpp 复制代码
sin_port

这样的协议字段,需要:

cpp 复制代码
htons();
ntohs();

因为 TCP/IP 协议规定这些数字字段使用:

text 复制代码
网络字节序

但是:

cpp 复制代码
write(sock, buff, n);
read(sock, buff, n);

并不会自动把你:

text 复制代码
buff

里的所有整数从小端改成大端,或者再自动改回来。

例如:

cpp 复制代码
int x = 0x12345678;

write(sock, &x, sizeof(x));

write() 只会把:

text 复制代码
x 在当前内存中的 4 个字节

交给内核。

不会理解:

这 4 个字节原来是一个 int,我替你做一下 htonl()

因此真正需要跨平台传递数字时:

text 复制代码
程序员自己定义协议
↓
明确哪些字段是数字
↓
自己完成序列化和字节序转换

网络接口负责的是:

text 复制代码
传输字节

而不是理解你应用层数据的类型。


7.9 TIME_WAIT 和服务器快速重启

测试时可能遇到:

text 复制代码
关闭 Server
↓
立刻重新启动
↓
bind: Address already in use

这和 TCP 连接关闭后的状态有关。

在 TCP 四次挥手中:

主动关闭的一方可能进入 TIME_WAIT。

所以不能简单记成:

text 复制代码
Server 一定 TIME_WAIT

而应该记:

text 复制代码
谁主动完成关闭
↓
谁可能进入 TIME_WAIT

如果服务器开发过程中需要频繁重启,可以使用:

cpp 复制代码
setsockopt();

设置:

text 复制代码
SO_REUSEADDR

后面马上封装。


8. 封装 Socket 接口

到现在我们已经反复写过:

cpp 复制代码
socket();
bind();
listen();
accept();
connect();
close();

这些代码。

继续往后的网络项目还会不断使用。

所以应该将它们统一封装。


8.1 Socket.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>

enum
{
    SOCKET_ERR = 2,
    BIND_ERR,
    LISTEN_ERR
};

const int backlog = 10;

class Sock
{
public:
    Sock()
        : sockfd_(-1)
    {}

    ~Sock()
    {}

    void Socket()
    {
        sockfd_ = socket(AF_INET, SOCK_STREAM, 0);

        if(sockfd_ < 0)
        {
            std::cerr << "socket error: "
                      << strerror(errno)
                      << std::endl;

            exit(SOCKET_ERR);
        }

        int opt = 1;
        setsockopt(sockfd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    }

    void Bind(uint16_t port)
    {
        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;

        if(bind(sockfd_, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            std::cerr << "bind error: "
                      << strerror(errno)
                      << std::endl;

            exit(BIND_ERR);
        }
    }

    void Listen()
    {
        if(listen(sockfd_, backlog) < 0)
        {
            std::cerr << "listen error: "
                      << strerror(errno)
                      << std::endl;

            exit(LISTEN_ERR);
        }
    }

    int Accept(std::string* clientip, uint16_t* clientport)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        int newfd = accept(sockfd_, (struct sockaddr*)&peer, &len);

        if(newfd < 0)
            return -1;

        char ipstr[64];
        inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr));

        *clientip = ipstr;
        *clientport = ntohs(peer.sin_port);

        return newfd;
    }

    bool Connect(const std::string& ip, uint16_t port)
    {
        struct sockaddr_in peer;
        memset(&peer, 0, sizeof(peer));

        peer.sin_family = AF_INET;
        peer.sin_port = htons(port);

        inet_pton(AF_INET, ip.c_str(), &peer.sin_addr);

        int n = connect(sockfd_, (struct sockaddr*)&peer, sizeof(peer));

        if(n == -1)
            return false;

        return true;
    }

    void Close()
    {
        close(sockfd_);
    }

    int Fd() const
    {
        return sockfd_;
    }

private:
    int sockfd_;
};

以后服务器初始化就从:

text 复制代码
一大堆系统调用

变成:

cpp 复制代码
listensock_.Socket();
listensock_.Bind(port_);
listensock_.Listen();

获取连接:

cpp 复制代码
int sockfd = listensock_.Accept(&clientip, &clientport);

客户端连接:

cpp 复制代码
sock_.Socket();
sock_.Connect(server_ip, server_port);

代码结构会清楚很多。


8.2 setsockopt

刚才:

cpp 复制代码
Socket();

中还有:

cpp 复制代码
int opt = 1;

setsockopt(sockfd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

setsockopt() 用来:

修改 Socket 的各种工作选项。

Linux 常见函数形式:

cpp 复制代码
int setsockopt(int sockfd,
               int level,
               int optname,
               const void* optval,
               socklen_t optlen);

其中:

text 复制代码
sockfd
↓
修改哪个 Socket

level
↓
选项属于哪一层

optname
↓
具体哪个选项

optval
↓
选项值

optlen
↓
选项值长度

8.3 常见 Socket 选项

SOL_SOCKET

常见:

text 复制代码
SO_REUSEADDR
↓
允许地址在特定情况下快速重新绑定,
开发服务器快速重启时经常使用

SO_RCVBUF
↓
接收缓冲区大小

SO_SNDBUF
↓
发送缓冲区大小

SO_KEEPALIVE
↓
开启 TCP Keepalive 检测

SO_LINGER
↓
控制 close 时未发送数据的处理方式

SO_DEBUG
↓
Socket 调试相关

SO_OOBINLINE
↓
带外数据相关

还有:

text 复制代码
SO_BROADCAST

用于允许广播发送,主要和 UDP 数据报场景有关。


IPPROTO_TCP

TCP 特有选项例如:

text 复制代码
TCP_NODELAY
↓
关闭 Nagle 算法,
需要更低小包延迟时可能使用

TCP_MAXSEG
↓
TCP 最大报文段相关设置

IPPROTO_IP

例如:

text 复制代码
IP_MULTICAST_IF
IP_MULTICAST_TTL
IP_ADD_MEMBERSHIP
IP_DROP_MEMBERSHIP

主要用于:

text 复制代码
IP 多播

这种场景更加常见于 UDP。

本篇不继续展开。

现在只需要建立:

Socket 行为不是固定死的,内核提供了大量 Socket Option 允许程序调整。


总结

这一篇真正把 TCP Socket 编程的主线完整走通了。

最基础的 TCP Server:

text 复制代码
socket()
   ↓
bind()
   ↓
listen()
   ↓
accept()
   ↓
read() / write()

TCP Client:

text 复制代码
socket()
   ↓
connect()
   ↓
read() / write()

其中一定要区分:

text 复制代码
listen_sock
↓
监听、获取新连接

service_sock
↓
真正和某一个客户端通信

一个服务器如果同时连接三个 Client:

text 复制代码
1 个监听 Socket
+
3 个通信 Socket

单进程版本:

text 复制代码
accept
↓
Service
↓
Service 不退出
↓
无法继续 accept

所以必须让:

text 复制代码
获取连接

和:

text 复制代码
处理连接

进入不同执行流。

第一种:

text 复制代码
多进程

父进程
↓
accept

子进程
↓
Service

然后又需要解决:

text 复制代码
子进程退出
↓
僵尸进程

因此学习了:

text 复制代码
waitpid + WNOHANG
SIGCHLD 信号处理
SIGCHLD -> SIG_IGN
双重 fork

进一步:

text 复制代码
多线程

变成:

text 复制代码
主线程
↓
accept

工作线程
↓
Service

通过:

text 复制代码
ThreadData

将:

text 复制代码
sock
IP
Port
this

一起传给线程。

再使用:

cpp 复制代码
pthread_detach();

让工作线程自动回收。


继续优化:

text 复制代码
来一个连接
↓
创建一个线程

变成:

text 复制代码
程序启动
↓
提前创建 Worker
↓
accept
↓
Task Queue
↓
线程池处理

这又重新回到了以前学习过的:

text 复制代码
生产者消费者模型

随后将 Server:

text 复制代码
脱离控制终端
+
长期后台运行

就得到了:

text 复制代码
守护进程

这一过程中又把 Linux 进程体系串了起来:

text 复制代码
进程
↓
进程组
↓
会话
↓
控制终端

经典守护进程流程:

text 复制代码
fork
↓
setsid
↓
再次 fork
↓
chdir
↓
umask
↓
处理文件描述符

而 Linux 已经提供:

cpp 复制代码
daemon();

帮助我们直接完成常见守护进程化操作。


最后再从内核重新理解 TCP:

text 复制代码
connect()
↓
通知客户端 TCP 主动建立连接

三次握手
↓
双方操作系统 TCP 协议栈完成

accept()
↓
应用层取走已经建立好的连接

所以:

text 复制代码
connect 成功
≠
Server 已经执行 accept

也解释了为什么:

text 复制代码
单进程 Server 忙着服务 A

时:

text 复制代码
B 的 connect

仍然可能成功。


TCP 通信本身:

text 复制代码
应用层 buff
↓
write
↓
Socket 发送缓冲区
↓
TCP 自己决定什么时候发送


网络
↓
TCP 接收缓冲区
↓
read
↓
应用层 buff

所以:

text 复制代码
write/read

本质上首先是:

用户空间和内核 Socket 缓冲区之间的数据拷贝。

两边又分别具有发送和接收方向,因此 TCP 能够实现:

text 复制代码
全双工

但 TCP 是:

text 复制代码
面向字节流

所以:

text 复制代码
一次 write
≠
一次 read

真正的网络程序还必须继续解决:

如何从连续字节流中识别出一条完整消息?

这就需要:

text 复制代码
应用层协议

而这也正是后面网络编程要继续解决的问题。


最后把这一篇压成一条复习链:

text 复制代码
UDP 已会
  ↓
TCP 多了"连接"
  ↓
Server:socket -> bind -> listen -> accept
  ↓
Client:socket -> connect
  ↓
双方 read/write
  ↓
监听 Socket 和通信 Socket 必须分开
  ↓
单进程无法同时处理多个长连接
  ↓
多进程
  ↓
子进程回收
  ↓
多线程
  ↓
线程池
  ↓
守护进程
  ↓
连接其实由内核 TCP 维护
  ↓
connect 触发建连,accept 只是拿结果
  ↓
TCP 发送接收依赖 Socket 缓冲区
  ↓
TCP 全双工
  ↓
TCP 是字节流
  ↓
下一步:应用层协议

如果这一整条能够顺着说下来,那么 TCP Socket 编程这一篇的核心知识就真正串起来了。

相关推荐
younuo365516 分钟前
广州网站搭建费用明细:域名、服务器与开发成本全解析
服务器·前端·github
邪修king17 分钟前
Re:Linux 系统篇(十六):进程篇(五):O (1) 调度算法深度解析 —— 优先级数组、位图优化与活跃 / 过期双队列
linux·运维·算法
苏三福17 分钟前
ubuntu 在文件管理系统中用sftp打开远程文件空白的问题解决
linux·运维·ubuntu
OpsEye19 分钟前
公有+私有化混合大模型架构,流量治理底层原理科普
网络·架构
云飞云共享云桌面20 分钟前
10 人三维研发团队,如何利用单台图形服务器承载 SolidWorks 建模与仿真?
运维·服务器·网络·自动化·制造
天空之城--22 分钟前
C++ ODR-use 深度解析:从原理到实战
c++
在线考试系统推荐23 分钟前
拆成多道小题,优考试“理解题“怎么用?
服务器·人工智能·学习
邪修king23 分钟前
Re:Linux 系统篇(十四):进程篇(三):进程退出与特殊进程 —— 僵尸 Z 状态、孤儿进程深度解析
linux·运维·服务器
lingran__24 分钟前
Git 完全指南(一):本地仓库基础操作
linux·git·开发工具·devops·版本控制