Bootstrap

TCP并发服务器

端口号快速复用函数

通过getsockoptsetsockopt函数,管理套接字的端口号复用设置。具体操作如下:

getsockopt函数

int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);

功能:获取套接字的某些选项的属性。

参数

  1. sockfd: 套接字描述符。
  2. level: 获取的层级(例如SOL_SOCKET)。
  3. optname: 要获取的操作名称(如SO_REUSEADDR)。
  4. optval: 获取的值(0表示禁用,非0表示启用)。
  5. optlen: 参数4的大小。

返回值: 成功返回0,失败返回-1。


setsockopt函数

int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

功能:设置套接字的某些选项的属性。

参数

  1. sockfd: 套接字描述符。
  2. level: 设置的层级(例如SOL_SOCKET)。
  3. optname: 要设置的操作名称(如SO_REUSEADDR)。
  4. optval: 设置的值(0表示禁用,非0表示启用)。
  5. optlen: 参数4的大小。

返回值: 成功返回0,失败返回-1。


示例代码:端口号快速复用

// 获取当前端口号是否能快速复用
int n;
int len = sizeof(n);
getsockopt(oldfd, SOL_SOCKET, SO_REUSEADDR, &n, &len);
if (n == 0) {
    printf("端口号快速复用未启动\n");
} else {
    printf("端口号快速复用已经启动\n");
}

// 设置当前套接字端口号快速复用
n = 999;
setsockopt(oldfd, SOL_SOCKET, SO_REUSEADDR, &n, sizeof(n));


1. 循环服务器模型

描述

循环服务器模型是一次只处理一个客户端请求的传统方式,处理完一个客户端请求后,才会继续处理下一个客户端请求。由于其效率较低,每次只能执行单个任务,因此在高并发场景下表现不佳。

在循环服务器模型中,服务器端采用了一个循环来不断接收客户端请求。每当一个客户端连接成功后,服务器创建一个新的套接字用于通信,并处理客户端的请求。每处理完一个客户端请求后,服务器继续等待下一个客户端的连接。

主要步骤:

  1. 创建套接字:使用 socket() 函数创建套接字。
  2. 绑定:将套接字与服务器的 IP 地址和端口号绑定。
  3. 监听:开始监听客户端连接请求。
  4. 循环接收客户端请求
    • 每次 accept() 成功后,创建一个新的套接字用于通信。
    • 循环收发信息,直到客户端断开连接。
  5. 关闭连接:每次客户端断开连接后,关闭相应的套接字。
  6. 关闭监听套接字

循环服务器代码实现

代码示例
#include <myhead.h>
#define IP "192.168.60.45"
#define PORT 6666
#define BACKLOG 20

int main(int argc, const char *argv[]) {
    // 1. 创建套接字
    int oldfd = socket(AF_INET, SOCK_STREAM, 0);  // IPV4, TCP协议
    if (oldfd == -1) {
        perror("socket");
        return -1;
    }

    // 2. 获取端口号属性,查看是否启用端口号快速复用
    int n;
    int len = sizeof(n);
    if (getsockopt(oldfd, SOL_SOCKET, SO_REUSEADDR, &n, &len) == -1) {
        perror("getsockopt");
        return -1;
    }
    if (n) {
        printf("端口号快速复用已经启动\n");
    } else {
        printf("端口号快速复用未启动\n");
    }

    // 设置端口号快速复用
    n = 999;
    if (setsockopt(oldfd, SOL_SOCKET, SO_REUSEADDR, &n, sizeof(n)) == -1) {
        perror("setsockopt");
        return -1;
    }
    printf("端口号快速复用成功\n");

    // 3. 绑定IP和端口号
    struct sockaddr_in server = {
        .sin_family = AF_INET,  // IPV4
        .sin_port = htons(PORT),  // 端口号转换为网络字节序
        .sin_addr.s_addr = inet_addr(IP),  // IP地址
    };
    if (bind(oldfd, (struct sockaddr *)&server, sizeof(server)) == -1) {
        perror("bind");
        return -1;
    }

    // 4. 监听
    if (listen(oldfd, BACKLOG) == -1) {
        perror("listen");
        return -1;
    }

    // 5. 接受客户端连接请求并创建新的描述符用于通信
    struct sockaddr_in client;
    socklen_t client_len = sizeof(client);
    int newfd;

    while (1) {
        newfd = accept(oldfd, (struct sockaddr *)&client, &client_len);
        if (newfd == -1) {
            perror("accept");
            return -1;
        }
        printf("%s发来连接请求\n", inet_ntoa(client.sin_addr));

        // 6. 循环收发信息
        char buff[1024];
        while (1) {
            memset(buff, 0, sizeof(buff));
            int len = recv(newfd, buff, sizeof(buff), 0);  // 接收客户端信息
            if (len == 0) {  // 客户端断开连接
                printf("客户端下线\n");
                break;
            }
            printf("%s\n", buff);
            strcat(buff, "5201314");  // 添加回复信息
            send(newfd, buff, sizeof(buff), 0);  // 发送信息
        }
    }

    // 关闭套接字
    close(oldfd);
    close(newfd);
    return 0;
}
代码流程:
  1. 创建套接字socket() 函数创建一个 TCP 套接字。
  2. 设置端口号复用:通过 setsockopt() 设置端口复用,允许在同一端口上建立多个连接。
  3. 绑定:使用 bind() 将套接字与 IP 地址和端口绑定。
  4. 监听:调用 listen() 使套接字进入监听状态,准备接受客户端连接。
  5. 接受连接:通过 accept() 接受客户端连接,返回一个新的套接字描述符用于与客户端通信。
  6. 收发消息:在循环内通过 recv() 接收客户端消息,然后用 send() 发送回应消息。直到客户端断开连接。
  7. 关闭套接字:处理完成后关闭套接字。
缺点:
  • 同步阻塞:当前的设计是同步阻塞的,服务器在处理一个客户端请求时不能同时处理其他客户端的请求。因此,如果有多个客户端连接,必须等待前一个客户端的请求处理完成后才能继续。
  • 客户端阻塞:新客户端要通信时,必须等到旧客户端退出。服务器只能在当前连接断开后接受新的客户端请求。
  • 新客户端要通信时,必须等待旧客户端退出。
改进:

为了解决这个问题,可以采用 多线程多进程 模型来并发处理客户端请求,从而避免客户端之间的阻塞。


2. 基于TCP的并发服务器

2.1 多进程并发服务器

多进程并发服务器的模型通常由一个父进程负责监听客户端的连接请求,每当接收到一个连接请求时,父进程创建一个新的子进程来处理与客户端的通信。父进程和子进程之间通过文件描述符传递数据。父进程负责监听和接收客户端连接,子进程则处理数据收发。

多进程服务器模型的关键问题:

1. 子进程在哪创建: 父进程在接收到客户端的连接请求时会创建一个子进程来处理该请求。

示例代码:

pid_t pid = fork();
if (pid > 0) 
{
    // 父进程:继续监听新的连接请求,关闭新文件描述符
    close(newfd);
} 
else if (pid == 0) 
{
    // 子进程:关闭旧的监听套接字,并处理客户端通信
    close(server_fd);
    // 与客户端通信...
    close(newfd); // 处理完成后关闭与客户端的连接
    exit(0); // 子进程处理完请求后退出
} 
else 
{
    perror("fork");
    return -1;
}

2. 子进程怎么回收:子进程处理完任务后会退出,因此需要确保父进程能够回收已经退出的子进程,防止僵尸进程的产生。

常见的方式:

  • 阻塞回收:父进程使用 wait()waitpid() 函数在子进程退出时阻塞等待并回收它们。
  • 非阻塞回收:如果不想让父进程被 wait() 阻塞,可以使用信号处理机制,通过捕捉 SIGCHLD 信号并在信号处理函数中回收子进程。

示例代码(非阻塞回收):

// 信号处理函数,用于回收僵尸进程
void handle_sigchld(int sig) {
    while (waitpid(-1, NULL, WNOHANG) > 0);  // 回收已退出的子进程
}

// 在主程序中捕捉 SIGCHLD 信号
signal(SIGCHLD, handle_sigchld);

3. 文件描述符: 由于系统对打开的文件描述符有限制,因此需要确保在父子进程中正确管理文件描述符。

在操作系统中,文件描述符是有限的,每个进程可以打开的文件(包括套接字)的数量是有限的。如果服务器并发连接数较多,且每个连接都创建一个新的子进程,就可能遇到文件描述符耗尽的问题。可以通过以下方式解决或避免这一问题:

解决方案:

  • 增加文件描述符限制:可以通过 ulimit -n 命令临时增加系统的文件描述符限制,或者在 /etc/security/limits.conf 文件中永久增加限制。
  • 使用线程池或连接池:而不是为每个客户端创建一个新的子进程,可以通过线程池或连接池来管理客户端连接。多线程或线程池模型可以减少系统开销,因为线程的创建和销毁比进程更加轻量。
  • 复用文件描述符:通过一些技术手段(例如 select()poll()epoll())来管理多个连接,不需要为每个客户端创建一个独立的进程或线程,从而避免耗尽文件描述符。

示例:修改文件描述符限制

  1. 临时修改文件描述符的限制:
ulimit -n 65535
  1. 永久修改文件描述符的限制: 在 /etc/security/limits.conf 文件中添加: 
* soft nofile 65535
* hard nofile 65535

总结

  1. 子进程创建:父进程在接收到客户端连接请求后,使用 fork() 创建子进程来处理该客户端的通信。
  2. 子进程回收:父进程可以通过 wait()waitpid() 回收子进程,也可以通过信号处理函数来非阻塞回收已退出的子进程。
  3. 文件描述符限制:多进程模型可能会受到系统文件描述符限制的影响,解决方法包括增加文件描述符限制、使用线程池或连接池等技术来减少文件描述符的占用。
多进程并发服务器执行模型:

定义信号处理函数,非阻塞回收僵尸进程。

绑定子进程退出时的信号。

  1. 创建套接字
  2. 绑定
  3. 监听
  4. 循环接收客户端连接
  5. 让父进程接收客户端请求并关闭新文件描述符,子进程关闭旧的描述符只负责数据收发。
多进程服务器代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <wait.h>

#define IP "192.168.60.45"
#define PORT 6666
#define BACKLOG 100

// 信号处理函数,用于回收僵尸进程
void fun(int sss)
{
    if (sss == SIGCHLD)
    {
        while (waitpid(-1, NULL, 0) > 0); // 循环回收子进程
    }
}

int main(int argc, const char *argv[])
{
    // 1. 捕获子进程退出时的信号
    if (signal(SIGCHLD, fun) == SIG_ERR)
    {
        perror("signal");
        return -1;
    }

    // 2. 创建TCP类型的套接字
    int oldfd = socket(AF_INET, SOCK_STREAM, 0);
    if (oldfd == -1)
    {
        perror("socket");
        return -1;
    }

    // 3. 设置端口号快速复用
    int n = 1;
    if (setsockopt(oldfd, SOL_SOCKET, SO_REUSEADDR, &n, sizeof(n)) == -1)
    {
        perror("setsockopt");
        return -1;
    }
    printf("端口号快速复用成功\n");

    // 4. 绑定
    struct sockaddr_in server = {
        .sin_family = AF_INET,
        .sin_port = htons(PORT),
        .sin_addr.s_addr = inet_addr(IP),
    };
    if (bind(oldfd, (struct sockaddr *)&server, sizeof(server)) == -1)
    {
        perror("bind");
        return -1;
    }

    // 5. 监听
    if (listen(oldfd, BACKLOG) == -1)
    {
        perror("listen");
        return -1;
    }

    struct sockaddr_in client;
    socklen_t client_len = sizeof(client);
    char buff[1024];

    // 6. 主循环接收客户端连接
    while (1)
    {
        int newfd = accept(oldfd, (struct sockaddr *)&client, &client_len); // 接收客户端连接
        printf("%s发来连接请求\n", inet_ntoa(client.sin_addr));

        pid_t pid = fork(); // 创建子进程
        if (pid > 0) // 父进程监听,并关闭新的描述符
        {
            close(newfd);
        }
        else if (pid == 0) // 子进程处理数据收发
        {
            close(oldfd); // 子进程关闭旧的描述符
            while (1)
            {
                int len = recv(newfd, buff, sizeof(buff), 0);
                if (len == 0)
                {
                    printf("客户端退出\n");
                    break;
                }
                printf("客户端%s发来消息:%s\n", inet_ntoa(client.sin_addr), buff);
                strcat(buff, inet_ntoa(client.sin_addr)); // 回去时加上客户端IP
                send(newfd, buff, sizeof(buff), 0);
            }
            close(newfd);
            exit(0); // 子进程退出
        }
        else
        {
            perror("fork");
            return -1;
        }
    }
    return 0;
}

客户端代码:

客户端通过连接到服务器来发送数据并接收服务器的响应。该程序创建一个TCP套接字,连接到指定的服务器IP和端口,并与服务器进行数据交互。

#include <myhead.h>

#define IP "192.168.60.45"
#define PORT 6666

int main(int argc, const char *argv[])
{
    // 1. 创建套接字
    int oldfd = socket(AF_INET, SOCK_STREAM, 0);
    if (oldfd == -1)
    {
        perror("socket");
        return -1;
    }

    // 2. 连接服务器
    struct sockaddr_in server = {
        .sin_family = AF_INET,
        .sin_port = htons(PORT),
        .sin_addr.s_addr = inet_addr(IP),
    };
    if (connect(oldfd, (struct sockaddr *)&server, sizeof(server)) == -1)
    {
        perror("connect");
        return -1;
    }

    // 3. 数据收发
    char buff[1024];
    while (1)
    {
        fgets(buff, sizeof(buff), stdin); // 键盘输入字符串
        buff[strlen(buff) - 1] = '\0';   // 去除换行符

        // 发送数据到服务器
        send(oldfd, buff, sizeof(buff), 0);

        // 接收服务器数据
        int len = recv(oldfd, buff, sizeof(buff), 0);
        if (len == 0)
        {
            printf("服务器意外退出\n");
            break;
        }
        printf("接收服务器消息:%s\n", buff);
    }

    // 关闭套接字
    close(oldfd);

    return 0;
}

 总结
  • 多进程并发模型: 通过父进程监听和创建子进程来处理每个客户端的请求,使用fork创建子进程,在子进程中负责数据收发,父进程关闭新文件描述符,子进程关闭旧文件描述符。通过信号处理函数回收子进程,避免僵尸进程。
  • TCP客户端: 连接到服务器后,通过套接字发送数据并接收响应,支持实时数据交互。

这种模型适用于客户端和服务器之间的实时通信,特别是在需要处理多个客户端请求的场景中。


2.2 多线程并发服务器

1. 多线程的优势
  • 资源开销小:线程的资源开销比进程小,创建和销毁线程的速度比进程快。
  • 响应速度快:如果客户端连接较多,可以通过线程池提前创建线程来分配给客户端,减少响应延迟。
  • 线程销毁开销小:线程占用资源较少,销毁线程的资源开销也较小。
2. 服务器模型
  • 主线程:负责监听客户端连接。
  • 子线程:负责接收和发送数据,与客户端通信。
3. 服务器的工作流程
  1. 创建原始套接字:使用 socket() 创建一个 TCP 类型的套接字。
  2. 绑定 IP 地址和端口号:使用 bind() 绑定服务器的 IP 地址和端口。
  3. 监听客户端连接:使用 listen() 开始监听客户端的连接请求。
  4. 接收客户端连接:使用 accept() 接收客户端的连接请求,并为每个客户端创建一个新线程。
  5. 线程处理客户端请求:每个线程接收到客户端消息后进行处理并发送响应。线程执行完后退出。
4. 线程池
  • 为了处理大量并发请求,线程池的机制可以预先创建一定数量的线程,接收到客户端请求时,从线程池中取出一个线程来处理该请求。
5. 代码实现
#include <myhead.h>

// 定义服务器的 IP 地址、端口号和最大连接数
#define IP "192.168.60.45"
#define PORT 9999
#define BACKLOG 10

// 定义结构体,用于传递客户端信息和新文件描述符
typedef struct {
    struct sockaddr_in client; // 客户端信息
    int newfd;                 // 新的文件描述符
} ZYJ;

// 线程体函数,处理与客户端的通信
void *fun(void *sss) {
    // 获取新文件描述符和客户端信息
    int newfd = ((ZYJ *)sss)->newfd;
    struct sockaddr_in client = ((ZYJ *)sss)->client;
    
    // 打印客户端的 IP 地址
    printf("%s发来信息\n", inet_ntoa(client.sin_addr));
    
    char buff[1024];
    while (1) {
        // 接收客户端发来的消息
        int len = recv(newfd, buff, sizeof(buff), 0);
        if (len == 0) { // 客户端退出
            printf("客户端退出\n");
            break;
        }
        
        // 打印收到的消息
        printf("收到消息:%s\n", buff);
        
        // 在消息末尾加上 "1973"
        strcat(buff, "1973");
        
        // 将处理后的消息发送回客户端
        send(newfd, buff, sizeof(buff), 0);
    }
    
    // 线程结束时退出
    pthread_exit(NULL);
}

int main(int argc, const char *argv[]) {
    // 1. 创建套接字
    int oldfd = socket(AF_INET, SOCK_STREAM, 0);
    if (oldfd == -1) {
        perror("socket");
        return -1;
    }

    // 2. 绑定 IP 地址和端口号
    struct sockaddr_in server = {
        .sin_family = AF_INET,
        .sin_port = htons(PORT),
        .sin_addr.s_addr = inet_addr(IP),
    };
    if (bind(oldfd, (struct sockaddr *)&server, sizeof(server)) == -1) {
        perror("bind");
        return -1;
    }

    // 3. 监听客户端的连接请求
    if (listen(oldfd, BACKLOG) == -1) {
        perror("listen");
        return -1;
    }

    struct sockaddr_in client;
    socklen_t client_len = sizeof(client);
    
    // 4. 主线程循环接收客户端连接
    while (1) {
        int newfd = accept(oldfd, (struct sockaddr *)&client, &client_len); // 接收客户端连接请求
        
        ZYJ sb;
        sb.newfd = newfd;          // 保存新的文件描述符
        sb.client = client;        // 保存客户端信息

        pthread_t tid;
        // 5. 创建子线程处理客户端请求
        tid = pthread_create(&tid, NULL, fun, &sb);
        if (tid == -1) {
            perror("pthread_create");
            return -1;
        }

        // 6. 将线程设为分离状态,系统会自动回收线程资源
        pthread_detach(tid);
    }

    return 0;
}
代码功能
  1. 创建套接字:通过 socket() 创建一个 TCP 套接字。
  2. 绑定 IP 地址和端口:通过 bind() 将套接字绑定到指定的 IP 地址和端口号。
  3. 监听连接:调用 listen() 开始监听来自客户端的连接请求,最多允许 BACKLOG 个连接排队。
  4. 接受连接:主线程通过 accept() 接收客户端的连接请求,并返回一个新的文件描述符 newfd 用于与客户端进行通信。
  5. 创建子线程:每当接受到新的连接,主线程会通过 pthread_create() 创建一个子线程处理客户端的消息传递和接收。每个子线程的处理逻辑通过 fun() 函数完成。
  6. 线程回收:通过 pthread_detach() 将线程设置为分离状态,子线程在完成后会自动回收资源,避免主线程等待子线程退出。
关键函数
  • socket():创建一个套接字。
  • bind():将套接字与本地的 IP 地址和端口号绑定。
  • listen():让套接字进入监听状态,准备接受客户端连接。
  • accept():阻塞等待客户端连接,并返回一个新的套接字用于与客户端通信。
  • pthread_create():创建新的线程来处理客户端请求。
  • pthread_detach():将线程设为分离状态,线程结束后自动回收资源。
  • recv():接收客户端发送的消息。
  • send():向客户端发送消息。
适用场景

这个多线程模型适用于客户端连接量较大或较为频繁的场景。每个客户端连接都会分配一个线程进行处理,因此可以在短时间内处理多个客户端的请求。

线程池优化

虽然该程序为每个连接创建了一个独立的线程,但在实际应用中,为了提高资源利用率和性能,可以引入线程池。线程池事先创建好一组线程,客户端请求到来时从线程池中取出一个线程来处理,而不是每次都创建一个新的线程。这样可以减少线程的创建和销毁开销。

注意事项
  • 多线程同步问题:虽然本例中没有涉及多线程之间的共享资源,但在复杂应用中可能需要考虑线程同步机制(如互斥锁、条件变量等)。
  • 线程回收:线程被设为分离状态(pthread_detach()),这样可以在线程结束后由系统自动回收资源,而不需要调用 pthread_join()

总结

该程序展示了一个典型的多线程并发服务器模型,使用线程处理每个客户端请求,减少了主线程的负担,提高了处理效率。通过 pthread_detach() 来自动回收资源,避免了线程泄露问题。


6.  客户端代码示例
#include <myhead.h>

#define IP "192.168.60.45"
#define PORT 9999

int main(int argc, const char *argv[])
{
    // 1、创建套接字
    int oldfd = socket(AF_INET, SOCK_STREAM, 0);
    if (oldfd == -1)
    {
        perror("socket");
        return -1;
    }

    // 2、连接服务器
    struct sockaddr_in server = {
        .sin_family = AF_INET,
        .sin_port = htons(PORT),
        .sin_addr.s_addr = inet_addr(IP),
    };
    if (connect(oldfd, (struct sockaddr *)&server, sizeof(server)) == -1)
    {
        perror("connect");
        return -1;
    }

    // 3、数据收发
    char buff[1024];
    while (1)
    {
        fgets(buff, sizeof(buff), stdin); // 键盘输入字符串
        buff[strlen(buff) - 1] = '\0';   // 去除换行符

        // 发送数据到服务器
        send(oldfd, buff, sizeof(buff), 0);

        // 接收服务器数据
        int len = recv(oldfd, buff, sizeof(buff), 0);
        if (len == 0)
        {
            printf("服务器意外退出\n");
            break;
        }
        printf("接收服务器消息:%s\n", buff);
    }

    // 关闭套接字
    close(oldfd);

    return 0;
}

;