Linux环境下C++ Socket编程实战指南

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:Linux系统下的C++ Socket编程是实现网络通信的关键技术。本压缩包提供了TCP服务器和客户端的示例程序,演示了如何利用标准Berkeley套接字API在Linux中实现socket通信。项目包括多客户端并发连接处理,利用"select"函数进行非阻塞监听,并提供了可直接编译运行的代码,便于学习网络编程。代码的移植性好,适合Unix-like系统。  linux-c++ socket编程

1. Linux C++ Socket编程基础

Linux C++ Socket编程概述

Linux C++ Socket编程是构建网络应用的核心技术之一,它允许开发者利用套接字(Socket)接口在应用程序之间进行数据传输。在C++中,我们通常利用Berkeley Socket API实现网络通信。这一章节旨在为读者提供一个坚实的基础,包括网络编程的基本概念、套接字的创建和配置,以及数据包的发送和接收。

基本概念和原理

网络编程涉及多个层次的概念,包括IP地址、端口号、协议栈、套接字等。开发者需要理解这些概念以及它们是如何协同工作的。Linux C++ Socket编程主要依赖于TCP/IP协议族,而TCP和UDP是两种常用的传输层协议,它们为数据传输提供了不同的保证。

套接字的创建和配置

为了开始网络通信,首先需要创建一个套接字。在C++中,我们通过调用socket()函数来创建一个新的套接字,随后配置这个套接字以适应特定的应用需求。配置通常包括指定套接字的类型(流式或数据报式)、选择协议(TCP或UDP)、绑定到特定的IP地址和端口等步骤。代码示例如下:

#include <sys/types.h> 
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>

// 创建一个TCP套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);

// 配置套接字地址结构体
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET; // 使用IPv4地址
server_addr.sin_addr.s_addr = INADDR_ANY; // 接受任何地址
server_addr.sin_port = htons(12345); // 端口号

// 绑定套接字到指定地址和端口
bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

// 设置为监听模式
listen(sockfd, 10); // 最大连接数为10

此代码段创建了一个TCP类型的套接字,并配置为监听12345端口上的连接请求。通过socket API进行这些基础操作是网络编程的起点,为我们后续章节中服务器和客户端之间的通信打下基础。

2. TCP服务器与客户端通信实现

2.1 TCP连接的建立和终止

2.1.1 TCP三次握手原理

TCP(传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。TCP在两个通信实体间建立连接时,采用的是“三次握手”(Three-way Handshake)机制。三次握手是保证TCP连接可靠性的关键步骤。

三次握手的步骤如下:

  1. 第一次握手 :客户端发送一个带有SYN(同步序列编号)标志位的TCP报文段给服务器,表示客户端请求建立连接,同时客户端进入SYN-SENT状态。
  2. 第二次握手 :服务器端接收到带有SYN标志位的报文段后,同意连接请求,并发送一个带有SYN和ACK(确认)标志位的TCP报文段给客户端,此时服务器进入SYN-RCVD状态。

  3. 第三次握手 :客户端收到服务器的确认信息后,发送一个ACK标志位的TCP报文段给服务器,此时客户端进入ESTABLISHED状态。服务器收到这个ACK后,也进入ESTABLISHED状态,双方建立起连接。

在三次握手的过程中,双方均确认了彼此的发送和接收能力,确保了后续数据传输的可靠性。

2.1.2 关闭TCP连接的四次挥手

在数据传输结束后,TCP连接需要被关闭。关闭过程涉及“四次挥手”(Four-way Handshake)机制。这个过程确保双方均有机会发送最后的数据并完成数据传输的结束。

四次挥手的过程为:

  1. 第一次挥手 :客户端或服务器端想要关闭连接,会发送一个带有FIN(结束)标志位的TCP报文段给对方,请求结束通信。发送FIN的一方进入FIN-WAIT-1状态。

  2. 第二次挥手 :收到FIN的一方进入CLOSE-WAIT状态,并且发送一个ACK报文段作为确认。此时,发送FIN的一方等待对方的最后确认,而接收方可以继续发送未发送完的数据。

  3. 第三次挥手 :数据发送完毕后,接收方发送一个带有FIN标志位的TCP报文段给请求关闭的一方,并进入LAST-ACK状态。

  4. 第四次挥手 :收到第二次挥手的FIN报文段的一方发送一个ACK报文段确认,并进入TIME-WAIT状态。发送方收到ACK后,连接正式关闭。如果接收方没有收到最后的ACK确认,会重传FIN报文段。

在四次挥手过程中,双方通过FIN和ACK标志位来协调关闭连接的步骤,保证双方均有机会发送完所有数据,确保数据不丢失。

2.2 编写TCP服务器程序

2.2.1 创建socket

在Linux C++环境中,使用socket API创建TCP服务器的第一步是创建一个socket。socket是一种通信端点,在程序中通过系统调用 socket() 创建。该函数会返回一个文件描述符,用于后续的网络操作。

下面是一个创建socket的示例代码:

#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <iostream>

int create_socket() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        std::cerr << "Socket creation failed" << std::endl;
        exit(EXIT_FAILURE);
    }
    return server_fd;
}

在这段代码中, AF_INET 表示使用IPv4地址, SOCK_STREAM 表示TCP类型的socket。创建socket的返回值被存储在变量 server_fd 中。如果返回值是-1,则表示创建socket失败,通常情况下会输出错误信息并退出程序。

2.2.2 绑定地址和端口

创建socket后,服务器需要绑定一个IP地址和端口号,以便客户端能够找到它。使用 bind() 系统调用可以将socket与IP地址和端口号绑定。IP地址和端口号的组合被称为socket的地址,通常使用 sockaddr_in 结构体来表示。

以下是一个绑定地址和端口的示例代码:

#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>

void bind_address_port(int server_fd, const char* ip_address, int port) {
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = inet_addr(ip_address);
    server_addr.sin_port = htons(port);

    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        std::cerr << "Bind failed" << std::endl;
        exit(EXIT_FAILURE);
    }
}

在这段代码中,首先将 server_addr 结构体的所有成员初始化为0(通过 bzero 函数)。然后设置地址族为 AF_INET ,IP地址通过 inet_addr 函数转换为网络字节序,并设置端口号通过 htons 函数转换为网络字节序。最后,调用 bind 函数将socket绑定到指定的地址和端口上。如果 bind 失败,程序会输出错误信息并退出。

2.2.3 监听和接受连接

服务器需要监听一个端口以接受客户端的连接请求。使用 listen() 函数可以将socket置于监听状态。一旦处于监听状态,服务器就可以接受客户端的连接请求了。使用 accept() 函数来接受一个连接请求,并返回一个新的socket,这个新的socket用于与客户端通信。

以下是一个监听和接受连接的示例代码:

#include <sys/socket.h>
#include <unistd.h>
#include <iostream>

int listen_and_accept(int server_fd) {
    if (listen(server_fd, 10) == -1) {
        std::cerr << "Listen failed" << std::endl;
        exit(EXIT_FAILURE);
    }

    socklen_t clilen = sizeof(struct sockaddr_in);
    struct sockaddr_in client_addr;
    int new_socket = accept(server_fd, (struct sockaddr *)&client_addr, &clilen);
    if (new_socket == -1) {
        std::cerr << "Accept failed" << std::endl;
        exit(EXIT_FAILURE);
    }

    return new_socket;
}

在这段代码中, listen() 函数将服务器的socket设置为监听状态,参数 10 表示可以排队的连接请求的最大数量。接着使用 accept() 函数接受一个连接请求。它返回一个新的socket文件描述符,用于与发起连接请求的客户端通信。如果 listen accept 失败,则输出错误信息并退出程序。

2.3 编写TCP客户端程序

2.3.1 创建socket

客户端创建socket的步骤与服务器基本相同,也是调用 socket() 函数来创建一个socket,并得到一个文件描述符。

2.3.2 连接到服务器

客户端创建socket后需要连接到服务器,这是通过 connect() 函数来实现的。客户端需要提供服务器的IP地址和端口号,以便进行连接。

下面是一个客户端连接到服务器的示例代码:

#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>

int connect_to_server(int client_fd, const char* ip_address, int port) {
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(port);
    server_addr.sin_addr.s_addr = inet_addr(ip_address);

    if (connect(client_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        std::cerr << "Connection failed" << std::endl;
        exit(EXIT_FAILURE);
    }

    return client_fd;
}

在这段代码中,首先初始化 server_addr 结构体,设置地址族为 AF_INET ,IP地址和端口号通过 inet_addr htons 函数转换为网络字节序。然后,调用 connect() 函数尝试与服务器建立连接。如果连接失败,程序输出错误信息并退出。

2.3.3 数据的发送和接收

一旦客户端成功连接到服务器,就可以通过 send() recv() 函数来发送和接收数据。

发送数据的示例代码如下:

#include <sys/socket.h>
#include <unistd.h>
#include <iostream>

void send_data(int socket_fd, const char* data) {
    if (send(socket_fd, data, strlen(data), 0) == -1) {
        std::cerr << "Send failed" << std::endl;
        exit(EXIT_FAILURE);
    }
}

接收数据的示例代码如下:

#include <sys/socket.h>
#include <unistd.h>
#include <iostream>
#include <cstring>

void receive_data(int socket_fd, char* buffer, size_t size) {
    ssize_t bytes_received = recv(socket_fd, buffer, size, 0);
    if (bytes_received == -1) {
        std::cerr << "Receive failed" << std::endl;
        exit(EXIT_FAILURE);
    }

    buffer[bytes_received] = '\0';
}

在发送和接收数据时,如果操作失败,会输出错误信息并退出程序。

通过上述步骤,TCP服务器和客户端程序可以完成基本的通信任务。这些步骤构成了TCP网络通信的基础,并为后续的高级功能和优化奠定了基础。

3. 处理多客户端连接

随着网络服务的发展和用户需求的增长,单一的客户端-服务器连接模型已经无法满足高并发场景的需求。为了实现服务器能够同时处理多个客户端请求,本章节将探讨如何处理多客户端连接,并介绍相关的策略和技术。

3.1 多线程与多进程模型

处理多个客户端连接,通常会使用多线程或多进程模型。每一种模型都有其自身的优势和局限性。

3.1.1 多线程模型的优缺点

多线程模型允许多个线程运行在同一个进程的地址空间内,这些线程共享进程的资源,例如文件描述符和内存段。多线程模型的优点主要表现在:

  • 资源共享 :由于线程共享资源,线程间的通信比进程间通信要简单高效。
  • 创建和上下文切换成本低 :与进程相比,线程的创建和上下文切换成本通常更低。
  • 易于实现并行处理 :对于多核处理器来说,多线程可以充分利用CPU资源进行并行处理。

然而,多线程模型也有一些显著的缺点:

  • 线程安全问题 :在多线程环境中,必须考虑线程同步和竞争条件,以保证数据的一致性和完整性。
  • 稳定性风险 :单个线程的崩溃可能会导致整个进程的失败。
  • 复杂性增加 :编写无错误的多线程代码比单线程代码更加困难。

3.1.2 多进程模型的优缺点

多进程模型涉及在操作系统中创建多个独立的进程,每个进程都有自己的内存空间,不共享内存。

多进程模型的优势包括:

  • 稳定性 :进程相互隔离,一个进程的崩溃不会直接影响到其他进程。
  • 安全性和隔离性 :由于进程间内存不共享,因此在安全性要求较高的环境下更加适用。
  • 继承和重用 :通过fork系统调用可以创建子进程,子进程可继承父进程的属性。

然而,多进程模型同样有其缺点:

  • 资源消耗大 :创建和维护进程需要更多的系统资源。
  • 通信和同步开销大 :进程间通信通常需要更复杂的机制,如管道、信号、套接字等。
  • 上下文切换成本高 :进程间的切换比线程间切换需要更多的CPU时间。

3.2 多客户端连接处理策略

在实现多客户端连接时,可以采用不同的策略来应对不同的需求和场景。

3.2.1 一个进程/线程处理一个客户端

最简单的多客户端处理策略是为每个客户端连接创建一个独立的进程或线程。这种模型常见于早期的网络服务设计。

优点包括:

  • 简单直观 :每个连接都有一个线程或进程来专门处理,容易理解。
  • 无需额外同步机制 :每个连接的处理线程完全独立,不需要复杂的同步机制。

缺点是:

  • 资源消耗巨大 :对于大量客户端连接来说,创建这么多线程或进程会迅速耗尽系统资源。
  • 性能开销 :线程或进程的创建和销毁本身就需要额外的开销,对性能有影响。

3.2.2 线程池模型

为了优化资源使用,线程池模型应运而生。线程池模型创建一组工作线程,并预先准备好这些线程以供使用。当新的客户端连接到来时,它会被分配给空闲的工作线程,而不需要为每个连接都创建新的线程。

优点包括:

  • 资源利用率高 :线程复用,减少了线程创建和销毁的开销。
  • 提升性能 :由于减少了线程创建的开销,能够更快地处理客户端请求。

缺点是:

  • 线程同步 :需要有效管理线程间的同步,以避免竞态条件和资源冲突。

3.3 管理多个socket连接

为了有效地管理多个客户端连接,需要有一套机制来跟踪和识别这些连接。常见的数据结构包括数组和链表。

3.3.1 使用数组管理socket

数组是一种简单的数据结构,可以通过索引快速访问数据元素。

优点是:

  • 随机访问 :可以快速通过索引访问特定的socket。

缺点是:

  • 大小固定 :一旦数组被初始化,其大小就固定了,无法动态扩展。
  • 不灵活 :对于动态变化的连接数,数组可能不是最佳选择。

3.3.2 使用链表管理socket

链表是一种灵活的数据结构,每个节点包含数据和指向下一个节点的指针。

优点是:

  • 动态扩展 :链表可以根据需要动态添加或删除节点。
  • 灵活性 :链表在处理不确定数量的数据时更加灵活。

缺点是:

  • 访问速度慢 :链表不支持随机访问,访问特定元素需要从头遍历。
  • 开销大 :链表中的每个节点都需要额外的空间来存储指针信息。

在管理多客户端连接时,具体选择哪种数据结构,通常取决于实际的应用场景和性能要求。在Linux环境下,可以使用 select poll epoll 等I/O多路复用技术来有效地管理大量socket连接,而不需要为每个连接分配一个单独的线程。

4. 使用select函数进行非阻塞I/O监听

4.1 select函数的工作原理

4.1.1 select函数的参数和返回值

select函数是UNIX或类UNIX系统中用于实现I/O复用的系统调用。它允许多个文件描述符(如套接字)在单个线程中进行非阻塞式的监视,以便确定它们中有哪些已经准备好进行读取、写入或异常处理。

其原型定义如下:

#include <sys/select.h>

int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict exceptfds, struct timeval *restrict timeout);
  • nfds :表示被监视的最大文件描述符数值加1。
  • readfds :指向文件描述符集合的指针,这些文件描述符等待被读取。
  • writefds :指向文件描述符集合的指针,这些文件描述符等待被写入。
  • exceptfds :指向文件描述符集合的指针,这些文件描述符等待异常事件。
  • timeout :指向timeval结构的指针,设置为NULL时函数将会无限期等待;设置为0时,函数将非阻塞地检查文件描述符状态并立即返回。

函数的返回值是一个整数,表示就绪(即可以无阻塞进行读写操作)的文件描述符的数量。返回0表示超时,-1表示错误。

4.1.2 监听多个socket的可读/可写状态

select函数的一大用途是监听多个socket连接的状态。传统的单线程服务器中,对每个socket分别进行阻塞式读取将会导致资源浪费,因为大部分时间线程都会在等待I/O操作完成。通过select函数,我们可以同时监视多个socket的状态,只有当某个socket有数据可读或可写时,才对其进行实际的读写操作。

以下是使用select函数进行多socket状态监听的基本流程:

  1. 初始化socket集合,并将需要监视的socket加入到这些集合中。
  2. 调用select函数,将这些集合作为参数传入。
  3. select函数会阻塞调用线程,直到至少有一个socket准备好I/O操作,或者超时。
  4. select函数返回后,根据返回的状态,检查各个socket集合,找出就绪的socket。
  5. 对就绪的socket进行读取或写入操作。

4.2 编写基于select的服务器程序

4.2.1 初始化和配置select监听

为了使用select函数,首先需要对socket集合进行初始化。下面是一个简单的示例代码,展示了如何初始化一个空的文件描述符集合,并设置为监视状态:

#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

// 初始化socket集合
fd_set readfds;
FD_ZERO(&readfds); // 清空集合

// 添加文件描述符到集合中
int server_fd = /* ... 服务器socket的文件描述符 ... */;
FD_SET(server_fd, &readfds);

// ... 可以添加更多的socket到集合 ...

// 设置超时时间
struct timeval timeout;
timeout.tv_sec = 5;  // 5秒
timeout.tv_usec = 0; // 0微秒

// 使用select监听socket
int ready = select(FD_SETSIZE, &readfds, NULL, NULL, &timeout);
if (ready < 0) {
    // 错误处理
    perror("select failed");
} else if (ready == 0) {
    // 超时处理
    printf("No socket ready to read\n");
} else {
    // 至少有一个socket准备好读取
    if (FD_ISSET(server_fd, &readfds)) {
        // ... 处理服务器socket的事件 ...
    }
    // ... 可以遍历并处理其他socket ...
}

4.2.2 循环监听和处理socket事件

服务器程序需要在一个循环中不断调用select函数,以持续监听多个socket的状态。每次调用select后,应该遍历所有已注册的socket,检查是否有数据可以读取或写入。

以下是循环监听socket事件的示例代码:

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    // ... 服务器初始化,创建socket,绑定地址等 ...

    int server_fd, client_fd;
    socklen_t client_len = sizeof(struct sockaddr_in);
    struct sockaddr_in server_addr, client_addr;

    while (1) {
        // 清空文件描述符集合
        FD_ZERO(&readfds);

        // 添加服务器socket到集合
        FD_SET(server_fd, &readfds);

        // ... 可以添加客户端socket到集合 ...

        // 调用select进行监听
        int ready = select(FD_SETSIZE, &readfds, NULL, NULL, &timeout);
        if (ready < 0) {
            perror("select failed");
            break; // 遇到错误则终止监听
        } else if (ready == 0) {
            // 超时
            printf("No socket ready to read\n");
        } else {
            // 有socket准备好读取
            if (FD_ISSET(server_fd, &readfds)) {
                // 接受新的连接
                client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
                // ... 将新连接加入到监听集合中 ...
            }
            // ... 可以遍历并处理其他socket事件 ...
        }
    }

    // ... 清理资源,关闭socket ...
    return 0;
}

4.3 select的性能优化

4.3.1 减少不必要的唤醒次数

虽然select函数允许我们监视多个socket,但是它也有性能瓶颈,尤其是在监视大量socket时。每次调用select时,整个监视集合都会被复制到内核,这是一个开销很大的操作。此外,每次只有部分socket就绪时,同样需要复制整个集合,这会增加不必要的唤醒次数。

为了减少不必要的唤醒,可以采取以下优化措施:

  • 限制监视的socket数量 :只监视有实际I/O活动的socket。
  • 使用标志变量 :在用户空间维护一组标志变量,记录socket的状态变化,减少内核空间和用户空间的数据复制。
  • 减少轮询频率 :对非关键任务使用较长的超时时间,减少select的调用频率。

4.3.2 使用epoll代替select(Linux特有)

为了进一步提高性能,特别是在处理大量并发连接时,Linux提供了epoll接口。epoll是对select和poll的改进,它使用事件通知的方式来高效地管理多个文件描述符。

与select不同,epoll仅在文件描述符状态发生变化时才返回,而不是每次调用都返回。此外,epoll在内核中维护了一个红黑树,用于管理被监视的文件描述符,这避免了每次调用时复制整个监视集合的开销。

使用epoll的示例代码:

#include <sys/epoll.h>
#include <unistd.h>

// 创建epoll实例
int epoll_fd = epoll_create1(0);
if (epoll_fd < 0) {
    perror("epoll_create1 failed");
    return -1;
}

// 将socket添加到epoll实例
struct epoll_event ev, events[10];
ev.events = EPOLLIN;
ev.data.fd = server_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev) < 0) {
    perror("epoll_ctl failed");
    close(epoll_fd);
    return -1;
}

// 循环处理事件
while (1) {
    int num_events = epoll_wait(epoll_fd, events, 10, -1);
    if (num_events < 0) {
        perror("epoll_wait failed");
        break;
    }
    for (int n = 0; n < num_events; ++n) {
        int fd = events[n].data.fd;
        // 处理事件,例如接收数据,发送数据等
    }
}

// 清理资源
close(epoll_fd);

通过使用epoll,可以大大提升系统的并发处理能力,尤其是在高负载的网络服务器中。

5. 可直接运行的示例程序

5.1 实现一个简单的echo服务器

5.1.1 设计echo服务器的架构

Echo服务器是一个基础的服务器模型,用于演示如何接收客户端发送的消息,并将同样的消息回传给客户端。其架构设计简单,但可以作为学习和测试网络编程的起点。

一个echo服务器通常包含以下几个关键组件: - 监听套接字(Listener Socket) :用于监听来自客户端的连接请求。 - 客户端套接字(Client Socket) :与客户端建立的连接,用于数据的接收和发送。 - 数据接收和发送逻辑 :负责处理客户端发送过来的数据,并将其原样返回。 - 事件循环 :通常用select或poll等方法实现非阻塞I/O,以监听多个客户端套接字的活动。

5.1.2 编写代码并编译运行

在Linux环境下,我们可以使用C++编写一个简单的echo服务器程序。以下是一个基础的示例代码,该代码使用了POSIX标准库中的socket API和select函数。

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
#include <sys/select.h>

#define PORT 8080
#define BUFFER_SIZE 1024
#define MAX_CLIENTS 5

int main() {
    int server_fd, new_socket, client_sockets[MAX_CLIENTS];
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[BUFFER_SIZE] = {0};
    fd_set readfds;

    // 创建socket文件描述符
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }
    // 设置socket选项
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);
    // 绑定socket到地址和端口上
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }
    // 监听套接字
    if (listen(server_fd, MAX_CLIENTS) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }
    std::cout << "Listening on port " << PORT << "..." << std::endl;

    // 处理客户端连接
    while (true) {
        // 初始化readfds集合
        FD_ZERO(&readfds);
        FD_SET(server_fd, &readfds);

        for (int i = 0; i < MAX_CLIENTS; i++) {
            client_sockets[i] = 0;
            FD_SET(client_sockets[i], &readfds);
        }

        // 等待客户端的连接请求
        if (select(server_fd + 1, &readfds, NULL, NULL, NULL) < 0) {
            perror("select");
            exit(EXIT_FAILURE);
        }

        // 检查是否有新的连接
        if (FD_ISSET(server_fd, &readfds)) {
            if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
                perror("accept");
                exit(EXIT_FAILURE);
            }

            std::cout << "New connection, socket fd is " << new_socket << ", ip is : "
                      << inet_ntoa(address.sin_addr) << ", port : " << address.sin_port << std::endl;

            for (int i = 0; i < MAX_CLIENTS; i++) {
                if (client_sockets[i] == 0) {
                    client_sockets[i] = new_socket;
                    break;
                }
            }
        }

        // 处理每个客户端发送的数据
        for (int i = 0; i < MAX_CLIENTS; i++) {
            int sock = client_sockets[i];

            if (FD_ISSET(sock, &readfds)) {
                memset(buffer, 0, BUFFER_SIZE);
                if (read(sock, buffer, BUFFER_SIZE) < 0) {
                    perror("recv failed");
                    exit(EXIT_FAILURE);
                }

                if (strlen(buffer) > 0) {
                    std::cout << "Client: " << buffer << std::endl;
                    send(sock, buffer, strlen(buffer), 0);
                }
            }
        }
    }

    return 0;
}

在上述代码中,我们首先创建了一个socket,并将其绑定到本地的8080端口上。然后,我们进入一个无限循环,不断监听来自客户端的连接请求和数据。当新的客户端连接时,我们将其添加到客户端套接字数组中,并继续监听后续的通信。

代码逻辑逐行解读分析
  • if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) :创建一个新的socket。
  • setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)) :设置socket选项,允许地址和端口重用,有助于处理临时断开后立即重新使用的场景。
  • bind(server_fd, (struct sockaddr *)&address, sizeof(address)) :将socket绑定到指定的IP地址和端口上。
  • listen(server_fd, MAX_CLIENTS) :使服务器处于监听状态,准备好接受客户端的连接请求。
  • while (true) :进入一个无限循环,等待客户端连接。
  • FD_ZERO(&readfds) :初始化描述符集合,准备进行select调用。
  • FD_SET(server_fd, &readfds) :将服务器监听套接字加入到集合中,以便select函数可以监视它。
  • select(server_fd + 1, &readfds, NULL, NULL, NULL) :等待事件发生,即等待有新的连接请求或者客户端发送数据。
  • accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen) :接受新的连接请求,并返回新的socket文件描述符,用于之后与该客户端的数据交换。
  • read(sock, buffer, BUFFER_SIZE) :读取来自客户端的数据。
  • send(sock, buffer, strlen(buffer), 0) :将接收到的数据发送回客户端。

将上述代码保存为一个文件,比如 echo_server.cpp ,然后使用g++编译器进行编译:

g++ echo_server.cpp -o echo_server

编译成功后,运行生成的 echo_server 可执行文件:

./echo_server

运行后,echo服务器将在本地的8080端口上开始监听连接请求。客户端可以连接到此服务器并发送消息,服务器将把消息回显给客户端。

接下来,我们将实现一个简单的客户端程序,用于连接到echo服务器并发送消息。

6. 高效的并发连接处理方法

在高性能网络服务应用中,处理大量并发连接是不可避免的挑战。本章将深入探讨如何在Linux环境下高效地处理并发连接,并介绍实现高效并发模型的方法。

6.1 并发连接的概念和挑战

6.1.1 并发连接数的限制因素

在讨论并发连接处理之前,我们首先需要了解影响并发数的因素。对于基于TCP协议的服务器而言,主要的限制因素包括:

  • 系统资源 :每个TCP连接都需要一定量的内存资源用于维护状态信息。
  • 文件描述符限制 :每个进程打开的文件描述符数量有限制,可以通过 ulimit -n 查看和修改。
  • 上下文切换开销 :多线程或多进程模型中,频繁的线程或进程调度会带来较大的上下文切换开销。
  • 网络I/O带宽和延迟 :网络带宽和延迟也会影响服务器的处理能力。

6.1.2 提高并发处理的策略

为了提高并发连接处理能力,可以采取以下策略:

  • 优化算法 :使用高效的算法和数据结构来减少资源消耗。
  • 多路复用技术 :使用如 select poll epoll kqueue 等I/O多路复用技术减少开销。
  • 异步I/O模型 :如使用 io_uring 等异步I/O操作,可以显著提升I/O性能。
  • 负载均衡 :通过多个服务器分散负载,可以在架构上提升并发处理能力。

6.2 高效并发模型的实现

6.2.1 使用epoll或kqueue提升性能(Linux/FreeBSD特有)

Linux的 epoll 和FreeBSD的 kqueue 是高级的I/O多路复用接口,它们可以高效地管理成千上万的并发连接。

  • epoll的高效性

    epoll 相比 select poll 具有更高的效率,主要体现在:

    • 只返回活跃的socket epoll 只返回那些活跃的socket,而不会在每次调用时返回所有注册的socket。
    • 低内存消耗 epoll 使用红黑树管理事件,不需要复制整个事件集合。
    • 边缘触发和水平触发 epoll 支持边缘触发模式,进一步优化了性能,但需要编写更复杂的逻辑来保证数据完整性。

    下面是一个简单的使用 epoll 的代码示例:

    ```c

    include

    int epfd = epoll_create1(0); // 创建epoll实例

    struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = fd; // 设置监听的文件描述符

    epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // 将文件描述符添加到epoll实例中

    int nfds = epoll_wait(epfd, events, 10, -1); // 等待事件发生

    // 处理事件... ```

  • kqueue的相似性

    kqueue 是FreeBSD中与 epoll 类似的接口,它也提供了高效处理大量事件的能力。

6.2.2 基于事件的回调模型

基于事件的回调模型允许服务器在事件发生时调用相应的处理函数,这种模型通常与I/O多路复用技术结合使用。

  • Reactor模式

    Reactor模式是一种常见的事件处理模式。在这种模式中,事件处理器(Handler)注册到一个事件分发器(Event Demultiplexer)中,当事件发生时,事件分发器会通知事件处理器进行处理。

    Reactor模式通常结合 epoll kqueue 一起使用,以提高网络服务器的性能和响应能力。

6.3 标准POSIX接口的代码移植性

6.3.1 POSIX标准概述

POSIX(Portable Operating System Interface)是一系列IEEE标准,旨在提高应用程序在不同UNIX系统之间的可移植性。在编写并发程序时,使用POSIX标准接口,如 epoll ,可以提高代码在不同平台间的移植性。

6.3.2 移植性和可维护性提升技巧

为了确保并发连接处理代码的移植性和可维护性,可以采取以下措施:

  • 避免平台特定的扩展 :尽可能使用POSIX标准定义的接口和功能,以减少平台依赖。
  • 抽象化实现 :对于I/O多路复用和事件处理,可以使用抽象层封装具体平台的差异。
  • 使用跨平台构建系统 :如使用 Meson CMake 等构建系统,可以简化多平台的编译和配置。
  • 保持代码清晰 :清晰的代码结构和注释有助于其他开发者理解代码,降低维护难度。

通过这些策略,开发者可以确保编写的并发处理代码具有更好的可移植性和可维护性,进而在不同平台间获得一致的性能表现。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:Linux系统下的C++ Socket编程是实现网络通信的关键技术。本压缩包提供了TCP服务器和客户端的示例程序,演示了如何利用标准Berkeley套接字API在Linux中实现socket通信。项目包括多客户端并发连接处理,利用"select"函数进行非阻塞监听,并提供了可直接编译运行的代码,便于学习网络编程。代码的移植性好,适合Unix-like系统。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值