【C++高并发服务器WebServer】-13:多线程服务器开发

本文目录

一、多线程服务器开发

服务端代码如下。

cpp 复制代码
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>

struct sockInfo {
    int fd; // 通信的文件描述符
    struct sockaddr_in addr; //客户端的信息
    pthread_t tid;  // 线程号
};

// 先定义好能够同时支持的客户端数量
struct sockInfo sockinfos[128];

void * working(void * arg) {
    // 子线程和客户端通信   cfd 客户端的信息 线程号
    // 获取客户端的信息
    // 参数是void * 类型的,所以需要进行强转
    struct sockInfo * pinfo = (struct sockInfo *)arg;

    char cliIp[16];
    inet_ntop(AF_INET, &pinfo->addr.sin_addr.s_addr, cliIp, sizeof(cliIp));
    unsigned short cliPort = ntohs(pinfo->addr.sin_port);
    printf("client ip is : %s, prot is %d\n", cliIp, cliPort);

    // 接收客户端发来的数据
    char recvBuf[1024];
    while(1) {
        int len = read(pinfo->fd, &recvBuf, sizeof(recvBuf));

        if(len == -1) {
            perror("read");
            exit(-1);
        }else if(len > 0) {
            printf("recv client : %s\n", recvBuf);
        } else if(len == 0) {
            printf("client closed....\n");
            break;
        }
        write(pinfo->fd, recvBuf, strlen(recvBuf) + 1);
    }
    close(pinfo->fd);
    return NULL;
}

int main() {

    // 创建socket
    int lfd = socket(PF_INET, SOCK_STREAM, 0);
    if(lfd == -1){
        perror("socket");
        exit(-1);
    }

    struct sockaddr_in saddr;
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(9999);
    saddr.sin_addr.s_addr = INADDR_ANY;

    // 绑定
    int ret = bind(lfd,(struct sockaddr *)&saddr, sizeof(saddr));
    if(ret == -1) {
        perror("bind");
        exit(-1);
    }

    // 监听
    ret = listen(lfd, 128);
    if(ret == -1) {
        perror("listen");
        exit(-1);
    }

    // 初始化数据,用整个数组的所占字节除以单个元素的大小,得到数组中的总数
    int max = sizeof(sockinfos) / sizeof(sockinfos[0]);
    for(int i = 0; i < max; i++) {
        //将sockinfos[i]这个地址中的所有内存大小都置为0
        bzero(&sockinfos[i], sizeof(sockinfos[i]));
        sockinfos[i].fd = -1; //-1表示是可用的文件描述符
        sockinfos[i].tid = -1;
    }

    // 循环等待客户端连接,一旦一个客户端连接进来,就创建一个子线程进行通信
    while(1) {

        struct sockaddr_in cliaddr;
        int len = sizeof(cliaddr);
        // 接受连接
        int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &len);

        // 局部变量当循环结束,就会释放,所以可以通过堆malloc来保存数据,但是子线程需要对应的去释放这个堆
        // 定义好结构体指针
        struct sockInfo * pinfo;
        for(int i = 0; i < max; i++) {
            // 从这个数组中找到一个可以用的sockInfo元素
            if(sockinfos[i].fd == -1) {
                pinfo = &sockinfos[i];
                break;
            }
            if(i == max - 1) {
                //也就是i=127的时候,sleep1秒,不然会继续下去创建子线程了
                sleep(1);
                // i--;
                i =  -1;
            }
        }
        pinfo->fd = cfd;

        //不可以用pinfo.addr = cliaddr进行赋值,可以通过里面对应的元素进行赋值
        memcpy(&pinfo->addr, &cliaddr, len);

        // 创建子线程,第四个参数是子线程需要的参数,且类型是 (void *) 类型,但是需要cfd、客户端信息、线程号
        // 所以可把需要的参数封装成一个结构体,然后把结构体传进去,这里就是第四个参数pinfo
        // pthread_t tid得等到pthread_create之后才会有对应的值,所以可以直接用&pinfo->tid来代替,这样可以直接给结构体中的tid进行赋值
        pthread_create(&pinfo->tid, NULL, working, pinfo);
        // void* 类型的指针是一种特殊的指针类型,可以指向任何类型的对象。
        // 也就是可以存储任何类型的指针值,但是不能直接对他进行解引用操作。
        // 在进行使用的时候,必须进行强转,将其转换为正确的类型。


        // 不可以用pthread_join();因为是阻塞的,这样就不能等待下一个客户端进来循环了。
        // 设置线程分离,让当前线程结束之后自己去释放资源,不需要父线程回收。
        pthread_detach(pinfo->tid);
    }

    close(lfd);
    return 0;
}

客户端代码如下:

cpp 复制代码
// TCP通信的客户端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

int main() {

    // 1.创建套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if(fd == -1) {
        perror("socket");
        exit(-1);
    }

    // 2.连接服务器端
    struct sockaddr_in serveraddr;
    serveraddr.sin_family = AF_INET;
    inet_pton(AF_INET, "127.0.0.1", &serveraddr.sin_addr.s_addr);
    serveraddr.sin_port = htons(9999);
    int ret = connect(fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));

    if(ret == -1) {
        perror("connect");
        exit(-1);
    }
    
    // 3. 通信
    char recvBuf[1024];
    int i = 0;
    while(1) {
        
        sprintf(recvBuf, "data : %d\n", i++);
        
        // 给服务器端发送数据
        //这里+1 是因为要算进去字符换行的结束符,不然会有问题。
        write(fd, recvBuf, strlen(recvBuf)+1);

        int len = read(fd, recvBuf, sizeof(recvBuf));
        if(len == -1) {
            perror("read");
            exit(-1);
        } else if(len > 0) {
            printf("recv server : %s\n", recvBuf);
        } else if(len == 0) {
            // 表示服务器端断开连接
            printf("server closed...");
            break;
        }

        sleep(1);
    }

    // 关闭连接
    close(fd);

    return 0;
}

运行下面代码可以看到效果如图:

二、TCP状态转换


主动断开连接的一方,最后进入一个TIME_WAIT状态,这个状态是定时经过两个报文段寿命(2MSL,Maximum Segment Lifetime)之后才会结束。

这里需要搞清楚一个点,假设客户端主动断开连接,当客户端发送FIN报文之后,服务端回一个ACK,然后客户端会进入FIN_WAIT_2状态,这个时候服务端可以继续向客户端发送数据,直到发送完该发送的数据之后,才会向客户端发送FIN报文,然后客户端会进入TIME_WAIT状态,从而经过2MSL断开。

这也就是为什么是四次挥手,而不是像三次握手一样,把ACK和FIN结合起来从而整体变成三次挥手。三次握手的时候是因为双方都希望能够建立连接,所以ACK和SYN可以结合。但是断开连接可能会有某一方"不愿意",还有需要发送的一个数据。可以理解成单方面的概念。

2MSL是为了保证安全和可靠性,因为有可能客户端回的最后一个ACK可能服务端会没收到,如果客户端立马断开,那么服务端会没断开,那么结束的状态是不完整的。没有接收到ACK,那么服务端会再次发送一个FIN,然后客户端再发一次ACK。

2MSL就是确保另外一方能够接收到ACK。Linux中msl一般是30s。

当 TCP 连接主动关闭方接收到被动关闭方发送的 FIN 和最终的 ACK后,连接的主动关闭方必须处于TIME_WAIT 状态并持续 2MSL 时间。这样就能够让 TCP 连接的主动关闭方在它发送的 ACK 丢失的情况下重新发送最终的 ACK。主动关闭方重新发送的最终 ACK 并不是因为被动关闭方重传了 ACK(它们并不消耗序列号被动关闭方也不会重传),而是因为被动关闭方重传了它的FIN。事实上,被动关闭方总是重传 FIN 直到它收到一个最终的 ACK。

有些程序就是有单方向发送的需求,所以可以用半关闭状态。

当 TCP 链接中A 向B发送 FIN 请求关闭,另一端 B 回应 ACK 之后(A 端进入 FIN WAIT 2状态),并没有立即发送 FIN 给 A,A方处于半连接状态(半开关),此时A可以接收B发送的数据,但是 A已经不能再向B发送数据。

可以通过API来实现半连接半关闭状态。

cpp 复制代码
#include <sys/socket.h>
int shutdown(int sockfd, int how);

sockfd: 需要关闭的socket的描述符。

how: 允许为shutdown操作选择以下几种方式:
SHUT_RD(0) : 关闭sockfd上的读功能,此选项将不允许sockfd进行读操作。该套接字不再接收数据,任何当前在套接字接受缓冲区的数据将被无声的丢弃掉。
SHUT_WR(1) : 关闭sockfd的写功能,此选项将不允许sockfd进行写操作。进程不能在对此套接字发出写操作。
SHUT_RDWR(2):关闭sockfd的读写功能。相当于调用shutdown两次:首先是以SHUT_RD,然后是SHUT_WR。

使用 close 中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为0时才关闭连接。shutdown 不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方向的连接,只中止读或只中止写。(在使用 fork 时,子进程会继承父进程的文件描述符,因此需要在父子进程中分别关闭不需要的文件描述符,以避免资源泄漏。)

如果有多个进程共享一个套接字,close 每被调用一次,计数减1,直到计数为0时,也就是所用进程都调用了 close,套接字将被释放。

在多进程中如果一个进程调用了 shutdown(sfd,SHUT_RDWR)后,其它的进程将无法进行通信。但如果一个进程 close(sfd)将不会影响到其它进程。

三、端口复用

在Linux中,有一些查看网络相关信息的命令。

cpp 复制代码
netstat:
netstat -a :显示所有的socket
netstat -p :显示正在使用socket的程序的名称
netstat -n :直接使用IP地址,而不通过域名服务器
netstat -t :显示TCP的socket
netstat -u :显示UDP的socket

首先运行下面的server 代码。

cpp 复制代码
#include <stdio.h>
#include <ctype.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[]) {

    // 创建socket
    int lfd = socket(PF_INET, SOCK_STREAM, 0);

    if(lfd == -1) {
        perror("socket");
        return -1;
    }

    struct sockaddr_in saddr;
    saddr.sin_family = AF_INET;
    saddr.sin_addr.s_addr = INADDR_ANY;
    saddr.sin_port = htons(9999);
    
    //int optval = 1;
    //setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
     //int optval = 1;
    //setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));

    // 绑定
    int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
    if(ret == -1) {
        perror("bind");
        return -1;
    }

    // 监听
    ret = listen(lfd, 8);
    if(ret == -1) {
        perror("listen");
        return -1;
    }

    // 接收客户端连接
    struct sockaddr_in cliaddr;
    socklen_t len = sizeof(cliaddr);
    int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);
    if(cfd == -1) {
        perror("accpet");
        return -1;
    }

    // 获取客户端信息
    char cliIp[16];
    inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, cliIp, sizeof(cliIp));
    unsigned short cliPort = ntohs(cliaddr.sin_port);

    // 输出客户端的信息
    printf("client's ip is %s, and port is %d\n", cliIp, cliPort );

    // 接收客户端发来的数据
    char recvBuf[1024] = {0};
    while(1) {
        int len = recv(cfd, recvBuf, sizeof(recvBuf), 0);
        if(len == -1) {
            perror("recv");
            return -1;
        } else if(len == 0) {
            printf("客户端已经断开连接...\n");
            break;
        } else if(len > 0) {
            printf("read buf = %s\n", recvBuf);
        }

        // 小写转大写
        for(int i = 0; i < len; ++i) {
            recvBuf[i] = toupper(recvBuf[i]);
        }

        printf("after buf = %s\n", recvBuf);

        // 大写字符串发给客户端
        ret = send(cfd, recvBuf, strlen(recvBuf) + 1, 0);
        if(ret == -1) {
            perror("send");
            return -1;
        }
    }
    
    close(cfd);
    close(lfd);

    return 0;
}

下面是client代码。

cpp 复制代码
#include <stdio.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

int main() {

    // 创建socket
    int fd = socket(PF_INET, SOCK_STREAM, 0);
    if(fd == -1) {
        perror("socket");
        return -1;
    }

    struct sockaddr_in seraddr;
    inet_pton(AF_INET, "127.0.0.1", &seraddr.sin_addr.s_addr);
    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(9999);

    // 连接服务器
    int ret = connect(fd, (struct sockaddr *)&seraddr, sizeof(seraddr));

    if(ret == -1){
        perror("connect");
        return -1;
    }

    while(1) {
        char sendBuf[1024] = {0};
        fgets(sendBuf, sizeof(sendBuf), stdin);

        write(fd, sendBuf, strlen(sendBuf) + 1);

        // 接收
        int len = read(fd, sendBuf, sizeof(sendBuf));
        if(len == -1) {
            perror("read");
            return -1;
        }else if(len > 0) {
            printf("read buf = %s\n", sendBuf);
        } else {
            printf("服务器已经断开连接...\n");
            break;
        }
    }

    close(fd);

    return 0;
}

查看对应的端口占用情况,可以看到是server程序正在占用9999端口。

然后再运行客户端,再查看一次情况,可以看到下面的情况,有两个server。一个server是用来监听的,一个server是用来通信的(也就是状态是Established的)。

当我们主动断开服务器之后,再查看一次状态,可以看到服务器的状态还在,但是不会显示server,状态是FIN_WAIT2。并且client的状态变成了Close_WAIT。


然后再过一段时间,服务端的信息也会没有了。

那么继续刚刚的过程,运行server和client,然后退出server,再立即启动server,会发现显示端口已占用,此时查看netstat情况,会发现处于FIN_WAIT_2的一个状态。

如果继续退出client,这个时候server会从FIN_WAIT_2变成TIME_WAIT状态,然后等待2msl就会退出。

这个时候就需要进行端口复用的设置,把server端中的下面两行代码的注释取消,然后再进行尝试,就可以发现不会显示端口绑定了。

端口复用就是为了解决防止程序服务器突然重启时,之前绑定的端口还没有释放,或者程序突然退出但是没有释放端口。

cpp 复制代码
int optval = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));

int optval = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));

来看看下面这个函数的作用。

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

// 设置套接字的属性(不仅仅能设置端口复用)
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t
optlen);

setsockopt 函数用于设置套接字的选项,它允许程序员对套接字的行为进行细粒度的控制。通过指定文件描述符 sockfd,可以针对特定的套接字进行操作。level 参数指定了选项所在的协议级别,例如 SOL_SOCKET 表示在套接字层面上的选项,这通常用于设置通用的套接字行为,如端口复用等。optname 参数指定了要设置的具体选项名称,比如 SO_REUSEADDR 或 SO_REUSEPORT,这些选项分别用于控制地址和端口的复用行为,允许在某些情况下多个套接字绑定到同一个地址和端口,这对于提高服务器的并发处理能力和快速重启服务非常有用。

optval 参数是一个指向值的指针,它指定了选项的具体值,通常是一个整型值,例如 1 表示启用某个选项(如允许复用),而 0 表示禁用该选项。optlen 参数则指定了 optval 参数所指向的值的大小,这在某些情况下用于确保数据的正确传递和解析。通过这些参数的组合,setsockopt 函数能够灵活地调整套接字的行为,以满足应用程序在不同场景下的需求,比如在开发高性能网络服务器时,合理设置这些选项可以显著提升系统的性能和可靠性。

具体可以看看UNIX网络编程这本书对端口复用的解释。

相关推荐
NuyoahC16 分钟前
仿 RabbitMQ 实现的简易消息队列
c++·分布式·rabbitmq·项目
Golinie19 分钟前
【C++高并发服务器WebServer】-17:阻塞/非阻塞和同步/异步、五种IO模型、Web服务器
服务器·c++·异步·webserver
24k小善22 分钟前
spark技术基础知识
服务器·spark
流星白龙44 分钟前
【Linux】30.Linux 多线程(4)
linux·运维·服务器
cchjyq1 小时前
opencv:基于暗通道先验(DCP)的内窥镜图像去雾
java·c++·图像处理·人工智能·opencv·计算机视觉
计算机视觉-Archer2 小时前
[NKU]C++安装环境 VScode
开发语言·c++
源代码•宸2 小时前
Leetcode—252. 会议室【简单】Plus
c++·经验分享·算法·leetcode·排序
Lambsbaa3 小时前
轻量级服务器http-server
运维·服务器·http
Golinie3 小时前
【C++高并发服务器WebServer】-14:Select详解及实现
linux·服务器·c++·select·webserver
wanhengidc3 小时前
网站服务器如何御防恶意网络爬虫攻击?
运维·服务器