TCP套接字编程实战:从服务器搭建到多进程处理

目录

[1. 对一个本地信息进行返回](#1. 对一个本地信息进行返回)

[1.1 服务端](#1.1 服务端)

[1.1.1 创建 socket 文件描述符](#1.1.1 创建 socket 文件描述符)

[1.1.2 填充本地网络信息并bind](#1.1.2 填充本地网络信息并bind)

[1.1.3 listen 开始监听socket (TCP, 服务器)](#1.1.3 listen 开始监听socket (TCP, 服务器))

[1.1.4 accept 接收请求 (TCP, 服务器)](#1.1.4 accept 接收请求 (TCP, 服务器))

[1.1.5 处理消息。](#1.1.5 处理消息。)

[1.2 客户端:](#1.2 客户端:)

[1.2.1 connect 建立连接 (TCP, 客户端)](#1.2.1 connect 建立连接 (TCP, 客户端))

2.通过孙子进程实现处理消息

3.进程信号版处理客户消息

4.多线程处理客户消息

5.通过线程池处理

6.守护进程


1. 对一个本地信息进行返回

1.1 服务端

1.1.1 创建 socket 文件描述符

创建 socket 文件描述符,指向套接字对象。成功时返回一个文件描述符,失败时返回 -1,并设置对应的错误码。

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

domain 用于指定通信类型:AF_UNIX 表示域间通信,AF_INET 表示网络通信,它是一个宏,值为 2。

type 用于指定套接字类型,常用的有两种:SOCK_STREAM 是流式套接字,通常用于 TCP;SOCK_DGRAM 是面向数据报的套接字,用于无连接的 UDP。此外还有 SOCK_RAW 底层原始套接字。第三个参数用于指定使用 TCP 还是 UDP 协议,在网络通信中,前两个参数固定后,第三个参数缺省为 0 即可。

1.1.2 填充本地网络信息并bind

  • 成功:返回 0

  • 失败:返回 -1,并设置全局变量 errno

cpp 复制代码
struct sockaddr_in local;
INADDR_ANY;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET; // 使用ipv4地址簇
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY; // 全0的ip htonl 加不加都行
int n = bind(_listensock, CONV(&local), sizeof(local));

1.1.3 listen 开始监听socket (TCP, 服务器)

客户端和服务器双方要进行数据通信,必须先建立连接。通常由客户端主动发起连接,而服务器则需要一直等待连接的到来,因此要将服务器设置为监听状态。

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>
int listen(int socket, int backlog);

将套接字设置为监听状态,即创建出的服务器套接字。第二个参数我们之后讲原理时再详细说明,现在先设为 5。成功返回 0,失败返回 -1,并设置错误码。

1.1.4 accept 接收请求 (TCP, 服务器)

cpp 复制代码
int accept(int socket, struct sockaddr* address,
                socklen_t* address_len);

后两个参数就是输入输出型参数获取客户端的 ip 地址端口号等信息,与 udp 中 recvfrom 中的两个参数相同。accept 默认会阻塞 ,但取决于监听套接字是"阻塞模式"还是"非阻塞模式"。成功返回新的套接字文件描述符 ,原来的 Listenfd(socket) **继续用于监听,**继续 accept 其他客户端。失败返回 -1,并设置错误码。也就是使用新的 fd 和 client 进行数据通信。

1.1.5 处理消息。

TCP 是面向字节流与管道和文件特性相同,可以read,write来处理,read返回 0 表示读到文件结尾 (对端关闭连接,如果只是对端没有关闭会阻塞,不会有返回值)。

cpp 复制代码
void Service(int sockfd)
    {
        //tcp 面向字节流与管道和文件特性相同,可以read,write来处理
        char buffer[1024];
        while(true)
        {
            //read 大于0,文件结尾,-1出错
            size_t n = read(sockfd,buffer,sizeof(buffer)-1);
            if(n > 0)
            {
                buffer[n] = 0;
                std::cout << "client say# " << buffer << std::endl;
            }
            string echo_string = "server echo# ";
            echo_string += buffer;
            write(sockfd,echo_string.c_str(),echo_string.size());//读到数据并返回
        }
        else if(n == 0)//read读到0表示读到文件结尾(对端关闭连接,没有关闭会阻塞)
        {
            lg.LogMessage(Info,"client quit...\n");
            break;
        }
        else
        {
            lg.LogMessage(Error,"accept socket error,errno code:%d,error string%s\n",
                        errno,strerror(errno));
            break;
        }
    }
}</code></pre>
netstat -nltp n:num,显示成数字形式,l:listen,监听状态;t:tcp;p:process; 显示出处于监听状态的 tcp 进程。

1.2 客户端:

inet_pton 把字符串形式的 IP 地址转换成二进制网络字节序地址,IPv4就会转化为4字节的网络字节序列。af 地址簇,src 原字符串 IP 地址,dst 输出缓冲区。IPv4 传struct in_addr*,IPv6 传 struct in6_addr*。

cpp 复制代码
#include <arpa/inet.h>
int inet_pton(int af, const char* src, void* dst);

//示例:

cpp 复制代码
inet_pton(AF_INET,serverip.c_str(),&server.sin_addr);

1.2.1 connect 建立连接 (TCP, 客户端)

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

连接成功,返回0,会自动 bind IP地址,端口号。连接失败,返回-1。然后我们通过 write 写入,如果服务端关了这里会产生一个sigpipe的信号,但对端关闭这里也会写入成功,因为这里是写到客户端自己进程的sockfd中了。

cpp 复制代码
#define CONV(addr_ptr) ((struct sockaddr*)addr_ptr)

bool visitServer(std::string& serverip,uint16_t& serverport)
{
    //1.创建socket
    int sockfd = socket(AF_INET,SOCK_STREAM,0);
    if(sockfd < 0)
    {
        std::cerr << "socket error" << std::endl;
        return false;
    }
int ret = true;

//client 不需要显示绑定;发起连接的时候,client会被 OS自动地进行本地绑定
//2.connet 连接server服务端
struct sockaddr_in server;//服务端信息
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
inet_pton(AF_INET,serverip.c_str(),&amp;server.sin_addr);//转换为网络字节序列
int n = connect(sockfd,CONV(server),sizeof(server));//成功自动bind
if(n != 0)
{
    std::cerr << " connect error " << std::endl;
    return false;
}

while(true)
{
    std::string inbuffer;
    std::cout << "Please Enter# ";
    getline(std::cin,inbuffer);
    if(inbuffer == "quit") 
        break;

    ssize_t n = write(sockfd,inbuffer.c_str(),inbuffer.size());
    //如果服务端关了这里会产生一个sigpipe的信号
    //对端关闭这里也会写入成功
    if(n > 0)
    {
        char buffer[1024];
        ssize_t m = read(sockfd, buffer,sizeof(buffer)-1);
        if(m > 0)
        {
            buffer[m] = 0;
            std::cerr << "get a echo message-&gt;" << buffer << std::endl;
        }
        else if(m == 0)
        {
            std::cout << "服务器关闭" << std::endl;
        }
        else
        {
            return false;
        }
    }
    else
    {
        return false;
    }
}
END:
close(sockfd);
return true;
}

2.通过孙子进程实现处理消息

子进程会继承父进程的 PCB 和文件描述符表,父子文件描述符表里的内容相同,指向相同的文件,我们可以让父线程获取链接成功,accept 返回 sockfd 后,创建子进程对客户端进行处理,并且父子进程关闭自己不需要的fd,否则会造成文件描述符泄露。

父进程创建子进程,子进程执行完,需要等待处理,变为僵尸进程 ,我们只能选择阻塞等待,因为如果设置为非阻塞, 可能有子进程处理完等待回收,但父进程循环 accept 等不到新任务,会 continue继续循环,所以非阻塞等待没有意义。但阻塞等待还是一次只能等待处理完一个客户,我们我可以让子进程创建孙子进程,然后让子进程退出,只留下孙子进程 ,这样父进程可以直接 wait 成功子进程,解决了子进程变成僵尸进程的问题,孙子进程处理任务,退出时因为他爹子进程没了,成了孤儿进程,由系统回收。这里 sockfd 为 accept 成功的返回值。

cpp 复制代码
pid_t id = fork();
if (id < 0)
{
    close(sockfd);
    continue;
}
else if (id == 0) // 子进程
{
    close(_listensock);
    if (fork() > 0)
        exit(0); // 子进程退出,waitpid立马返回,不影响父进程 accept
    // 孙子进程,正常处理,退出时因为他爹子进程没了,成立孤儿进程,系统回收
    Service(sockfd);
    close(sockfd);
    exit(0);
}
else // 父进程
{
    close(sockfd);
    pid_t rid = waitpid(id, nullptr, 0); // 阻塞等待,不关心退出状态
    // 如果 设置为非阻塞, 可能有子进程处理完等待回收,
    // 但父进程循环 accept 等不到新任务,会 continue继续循环,所以循环等待没有意义
    if (rid == id)
    {
        // 正确等待到了子进程
    }
}

3.进程信号版处理客户消息

在Linux中对 SIG_CHLD 进行忽略,子进程退出的时候,会自动释放自己的资源。

cpp 复制代码
        signal(SIGCHLD,SIG_IGN);
        pid_t id = fork();
        if (id < 0)
        {
            close(sockfd);
            continue;
        }
        else if (id == 0) // 子进程
        {
            close(_listensock);
            Service(sockfd);
            close(sockfd);
            exit(0);
        }
        else // 父进程
        {
            close(sockfd);
            //不等待,不关心子进程退出状态
        }

也可以使用进程池 --- 问题是先创建的子进程, 然后父进程才与客户端建立链接,子进程看不到新获取的文件fd,可以网上找代码。

4.多线程处理客户消息

因为不知道是新线程先执行还是主线程先执行,当主线程先执行时如果直接传入sockfd到新线程,如果新线程不及时执行,主线程中的 sockfd 可能导致被更新,所以需要创建一个 ThreadData 保存下来。意思就是不能这样写:新线程想要读取 sockfd 时可能已经晚了,确定的是地址,不是地址里面保存的值。

cpp 复制代码
int sockfd;
while (1) {
    sockfd = accept(listenfd, ...);
    pthread_create(&tid, NULL, worker, &sockfd);
}

正确做法:

cpp 复制代码
ThreadData *td = new ThreadData(sockfd, this, peer);
// 因为不知道是新线程先执行还是主线程先执行,当主线程先执行直接传入sockfd可能导致被更新
pthread_t tid;
pthread_create(&tid, nullptr, HandlerRequest, td);
// 主线程和新线程,不需要所谓的文件描述符表,因为共用
// pthread_join();//新线程需要被join,但此时主线程就被阻塞了,所以将线程分离

主线程和新线程,不需要所谓的文件描述符表,因为共用,不增加引用计数。不能使用 pthread_join() ,新线程需要被join,但此时主线程就会被阻塞,所以需要将线程进行分离。

同一个对象,this 指针的值是一样的。多线程共享同一个进程地址空间,对象只有一个,this 就是那个对象的地址。我们传入的方法不能直接是类内,因为 pthread_create 传入类型要求为 void *(*)(void *) ,而类内非静态成员函数类型第一个参数是this指针,所以我们把方法改为了静态,但内部还调用了一个类内 Server 方法,所以我们 ThreadData 也传入了this指针,处理不同的 sockfd 任务。

cpp 复制代码
class TcpServer;
class ThreadData
{
public:
    ThreadData(int sock, TcpServer *ptr, struct sockaddr_in &peer)
        : sockfd(sock), svr_ptr(ptr), addr(peer)
    {
    }
    int SockFd() { return sockfd; }
    TcpServer GetServer() { return svr_ptr; }
    ~ThreadData()
    {
    }
    int sockfd;
    TcpServer svr_ptr;
    InetAddr addr;
};

class TcpServer : public nocopy
{
public:
static void *HandlerRequest(void *args) // 包含this指针,
{
    pthread_detach(pthread_self());
    ThreadData *td = static_cast<ThreadData *>(args);
    td->GetServer()->Service(td->SockFd());
    //解决了,函数是static的,但内部使用了非静态成员
    close(td->SockFd());
    delete td;
    return nullptr;
}
}

使用线程安全的地址转换函数:

cpp 复制代码
char* inet_ntoa(struct in_addr in)

我们之前文章使用的是 inet_ntoa 来进行网络字节序列的 IP 到 字符串的转换,返回的是字符串地址,那么字符串在哪里,inet_ntoa这个函数返回了一个char*, 很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果。那么是否需要调用者手动释放呢?

man手册上说, inet_ntoa函数, 是把这个返回结果放到了静态存储区。这个时候不需要我们手动进行释放那么问题来了, 如果我们调用多次这个函数, 会有什么样的效果呢? 参见如下代码:

因为 inet_ntoa 把结果放到自己内部的一个静态存储区,这样第二次调用时的结果会覆盖掉上一次的结果。它返回指向静态缓冲区的指针,通常不可重入、非线程安全,连续调用会互相覆盖。

所以推荐使用 inet_ntop, 是把二进制 IP 地址转换成字符串的函数,可以看作 inet_ntop的线程安全、可重入、支持 IPv4/IPv6 替代品。1.网络转本机序列,2.字节风格ip转字符串风格ip

cpp 复制代码
const char* inet_ntop(int af,const void* src, char* dst, socklen_t size);
  • af:地址族,AF_INET 或 AF_INET6
  • src:指向 struct in_addr 或 struct in6_addr
  • dst:调用者提供的输出缓冲区
  • size:缓冲区 dst 大小

返回值:成功:返回 dst 指针。失败:返回 NULL,并设置 errno。

cpp 复制代码
char ipbuffer[64];
inet_ntop(AF_INET,&addr.sin_addr,ipbuffer,sizeof(ipbuffer));//

5.通过线程池处理

普通函数:名字会退化成指针,成员函数:名字不会退化成指针 TcpServer::Service 这不是一个指针,也不是一个可调用对象,只是一个"限定名"。在 C++ 里,成员函数的限定名不会自动退化成成员函数指针,你必须显式用 & 取地址,所以 bind 一个参数由重载的情况下这么写 void (TcpServer::*)(int, InetAddr)。而如果是不同函数不用加 &,直接写函数名称就行。所以在bind时需要加 &。

不能让我们的服务器提供长服务,否则会一直占用一个线程。

cpp 复制代码
// v5 线程池  --- 不能让我们的服务器提供长服务,以为会一直占用一个线程
InetAddr addr(peer);
// 1.lambda
task_t t = [this, sockfd, addr]()
{
    Service(sockfd, addr);
};
// 2.bind
// 要注意重载显示指出来,
// 成员函数的限定名不会自动退化成成员函数指针,你必须显式用 & 取地址
//  task_t t = std::bind(
//      static_cast<void(TcpServer::*)(int,InetAddr)>(&TcpServer::Service),
//      this,
//      sockfd,
//      addr);
ThreadNs::ThreadPool<task_t>::GetInstance()->Push(t);
应该调用这样的函数,执行完就退出,最后关闭对应的套接字。
void Routine(int sockfd,InetAddr addr)//路由
    { 
        funcs["defaultService"](sockfd,addr);
        std::string type = Read(sockfd);
        lg.LogMessage(Debug,"%s select %s\n", addr.PrintDebug().c_str(),type.c_str());
        if(type == "ping")
            funcs[type](sockfd, addr);
        else if(type == "translate")
            funcs[type](sockfd,addr);
        else if(type == "transform")
            funcs[type](sockfd,addr);
        else
            DefaultService(sockfd,addr);
    //线程这里就会退出  
    close(sockfd);//处理完关闭相关套接字
}

未来的服务器时部署在你的云服务器上的,你怎么知道你的服务未来在任何一个时刻,都是健康的呢?我们可以每隔比如30s向我们的服务器发送最小服务请求,如果得到了回复,说明我们的服务是正常的,这种机制称之为心跳机制。我们可以问client -> client,反向的server->client

cpp 复制代码
void Ping(int sockfd, InetAddr addr)
{
    // tcp 面向字节流与管道和文件特性相同,可以read,write来处理
    lg.LogMessage(Debug,"%s select %s success, fd :%d\n", 
                    addr.PrintDebug().c_str(),"ping",sockfd);
    char buffer[1024];
// read 大于0,文件结尾,-1出错
size_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
    buffer[n] = 0;
    std::cout << addr.PrintDebug() << " # " << buffer << std::endl;

    std::string echo_string = "server echo# Pong";
    write(sockfd, echo_string.c_str(), echo_string.size()); // 读到数据并返回
}
else if (n == 0) // read读到0表示读到文件结尾(对端关闭连接,没有关闭会阻塞)
{
    lg.LogMessage(Info, "client quit...\n");
}
else
{
    lg.LogMessage(Error, "accept socket error,errno code:%d,error string%s\n",
                  errno, strerror(errno));
}
}

6.守护进程

IO类函数,write/read 其实他们已经帮我们做了转网络序列。

udp:用户数据包 tcp:面向字节流,我们这里看起来没区别,尤其是网络IO的时候,用read,write,但目前的代码其实都是有Bug的!!!

  • 面向数据报,数据和数据是有边界的,用的是sendto,recvfrom。sendto 发一次,一定对应对端recvfrom一次。
  • 面向字节流,write 可能写了一次或多次,接收方可能一次就全部收完,也可能分多次读完,与发送发无关。如果一次读来多个,需要由用户分开处理。 而UDP数据报边界是很明显的,不再由用户处理

所以,tcp中要正确的读取,不出现错误,必须结合用户协议 。我们的网络服务,不能在bash中以前台进程的方式运行,真正的服务器,必须在linux后台 ,以 守护进程(精灵进程) 的方式进行运行。

除了进程的概念,还有进程组 PGID,作业,会话 SID。

命令行上执行的进程父进程都是bash,然后 PGID 就是进程组ID,SID是会话 ID;TPGID,t是终端,p就是进程,G是组,一般和PGID一样,它是当前前台进程组的 ID进程没有控制终端,或者终端没有前台进程组,TPGID 通常显示 -1;TTY,就是打开该进程的终端设备是谁,UID表示用户身份。PID 与 PGID 相同说明是一个自成进程组,进程组默认一定是在一个会话中的。兄弟进程可以通过匿名管道通信,同时启动多个进程,可以是属于同一个进程组的,组ID一般是多个进程中的第一个。

所以每次我们登录Linux,OS会给登录用户提供一个bash,提供一个终端(ssh),给用户提供命令行解析服务,叫做一个会话,会话与会话之间是具有隔离性的,在命令行中启动的所有进程,最终默认都是在当前会话内部的一个进程组(可以是一个进程自成进程组)。

一个用户可以有多个终端、多个 Bash、多个会话,它们通常互相独立,但共享同一个用户身份和权限。

一个终端里的一次"作业",通常就是一个进程组。比如运行 cat file | grep foo,这两个进程就属于同一个进程组。子进程默认继承父进程的进程组,通过 setpgid() 主动加入。

任何时刻,一个会话内部,可以存在很多个进程组(用户级任务),命令行一起执行的进程属于一个进程组,但是默认任何时刻,只允许一个进程组在前台(前台进程组),就是和终端相关和键盘相关。通过运行不同的进程**& 放到后台** ,可以创建多个进程组,使用 jobs 可以查看当前会话内部所有的进程组,fg 任务编号便可以放到前台 ,ctrl + z 再暂停放到后台 ,bg + 任务编号 让他再次运行。

进程组和用户级任务是一个概念,进程组就是来完成这些任务的,用户级任务只不过是用户层概念。会话和会话之间具有隔离性,用户退出,会话就会被释放,内部的进程组就会被释放。我们想让进程组不受用户登录和注销的影响,我们就需要把我们的进程变成守护进程。

守护进程是一个独立的会话,不隶属于任何一个 bash 会话。这个会话里只有它自己一个进程组,并以自己的进程组 ID(PGID),当作会话 ID(SID)。

cpp 复制代码
pid_t setsid(void);

如果成功,返回调用者的PID,也就是自己的PID,所以PID,PGID,SID相同,失败返回-1,错误码被设置。但如果要创建一个新的会话,要求当前进程不能是进程组组长。组长一般是多个进程的第一个,如果你只有一个进程,自成进程组,那组长就是你自己,所以一般要创建子进程,让父进程直接退出,后面代码都有子进程执行,因为父子进程代码共享。所以守护进程一定是孤儿进程。

cpp 复制代码
//1.忽略可能引起程序异常退出的信号
    signal(SIGCHLD,SIG_IGN);
    signal(SIGPIPE,SIG_IGN);
//2.退出父进程,自己不成为组长
    if(fork() > 0)
    {
        //父进程
        exit(0);
    }
//3.子进程自己成为新会话
    setsid();

更改工作目录

cpp 复制代码
    //4.每一个进程都有直接的CWD,
    //当前工作路径,是否更改当前进程的cwd更改成为 / 根目录
    if(ischdir) chdir(root);

任何一款Linux操作系统,都提供 /dev/null 的字符设备,凡是往这里写的任何东西,都自动被丢弃,凡是从这里读取,都会读到空,最大的特点是抛弃一切。已经变成守护进程,不需要再和用户的输入输出相关联了,我们使用 dup2 对文件重定向。

cpp 复制代码
    if(isclose)
    {
        close(0);close(1);close(2);
    }
    else
    {
        int fd = open(dev_null, O_RDWR);
        if (fd > 0)
        {
            dup2(fd, 0); // 将fd粘贴到0,1,2
            dup2(fd, 1);
            dup2(fd, 2);
            close(fd);
        }
    }

观察结果:

和终端无关,TTY是?,重新登陆进程还在。

通过kill 我们可以直接杀掉进程,也可以通过发送信号捕捉,把资源安全关闭,保存下数据再退出。

1.系统有没有提供把进程守护进程化的方法呢?

nochdir 为0,更改工作目录到 根目录,noclose设置为0,标准输入,标准输出,标准错误都设置到 /dev/null,否则什么都不变。

2.我们写的 tcp_server 和 udp_server 如何守护进程化

多线程程序 fork 后子进程只保留调用 fork 的那个线程。所以我们在 fork 前的父进程不要创建线程、不要持有复杂锁。所以daemon 函数调用要放在服务端创建创建多线程前面。

cpp 复制代码
    daemon(false,false);//守护进程化,放在 socket 前面
    std::unique_ptr<TcpServer> tsvr = make_unique<TcpServer>(port);

    tsvr->Init();


    lg.Enable(ClassFile);//日志打印重定向

    tsvr->Start();

TCP 建立连接要 三次握手,断开连接要 四次挥手,我们,底层就需要两个系统进行三次握手,断开连接因为双方都需要调用close,

三次握手:

  1. 客户端发 SYN,说:我想连接,我的初始序列号是 x。
  2. 服务端回 SYN+ACK,说:收到,我的初始序列号是 y,我确认你的 x。
  3. 客户端回 ACK,说:收到你的 y,连接建立。(确保可靠)

四次挥手:

  1. A 发 FIN,只表示 A 不再发送数据,但 A 还能接收数据。
  2. B 收到 FIN 后,先回 ACK,表示"我知道你不发了"。
  3. 但 B 可能还有数据没发完,不能立刻关闭自己的发送方向。
  4. 等 B 数据发完,再发自己的 FIN

本篇结束!

相关推荐
bigcarp1 小时前
安卓模拟器MUMU+tcpdump抓包Socket协议流量
服务器·网络·tcpdump
zhangzeyuaaa2 小时前
Ruby `require` 完全指南:从 `$LOAD_PATH` 到 `require_relative`
服务器·前端·ruby
易观Analysys2 小时前
AI时代IP产业的价值重构与生态版图
人工智能·tcp/ip·重构
fei_sun3 小时前
RIB、FIB、MAC 、ARP 表
服务器·网络·macos
凌云若寒4 小时前
BarTender提示#807错误:无法在拥有其他许可证的Licensing Service上激活节点锁定的 Professional 版许可证 的解决办法
运维·服务器·前端·学习·软件需求
Julien20044 小时前
管理 Ansible 配置文件
linux·运维·服务器·ssh·学习方法
逐米时代4 小时前
远程运维诊断减少到场率
java·服务器·前端
pt10434 小时前
CML网络仿真入门-6(结):Cisco Modeling Labs 示例拓扑
服务器·网络
风寄巴山秋5 小时前
OpenBMC:Web 页面功能异常排查
运维·服务器·前端·架构