基于Linux的C++网络编程

本文详细介绍了网络编程中的关键概念,包括使用socketAPI创建和管理套接字,如bind、listen、accept和connect函数。还讨论了多线程在网络通信中的应用,以及如何设置socket为阻塞或非阻塞模式。最后,探讨了I/O多路复用技术,如select、poll和epoll,以及它们在处理并发连接时的优缺点。

一,socket通信的过程 

二,相关函数及结构

1,socket函数

// 用于创建流式套接字
int socket(int domain, int type, int protocol)
// 如果成功,返回一个非负的文件描述符,表示新创建的套接字。
// 如果失败,返回 -1,并设置 errno 来指示错误的原因。
参数解释
domain要创建的sockfd的协议族,(AF_INET:IPv4协议族)、(AF_INET6:IPv6协议族)
type套接字类型(SOCK_STREAM:字节流套接字)、(SOCK_DGRAM:数据报套接字)
protocol协议类型(可以直接填写0,使用默认协议),(如果是TCP协议的话就填写IPPROTO_TCP,UDP和SCTP协议类似)

2,sockaddr_in结构

// 用于设置套接字

struct sockaddr {
    unsigned short    sa_family;  // 2 bytes 地址族, AF_xxx
    char              sa_data[14];// 14 bytes 协议地址
};

struct sockaddr_in {
    short            sin_family;  // 2 bytes 地址族,通常设置为AF_INET表示IPv4协议
    unsigned short   sin_port;    // 2 bytes 端口号,以网络字节序表示
    struct in_addr   sin_addr;    // 4 bytes IP地址,以网络字节序表示
    char             sin_zero[8]; // 8 bytes 填充字段,通常设置为0
};

struct in_addr {
    unsigned long s_addr;         // 4 bytes 使用inet_pton()加载
};

程序员不应操作sockaddr,sockaddr是给操作系统用的。程序员把类型、ip地址、端口填充sockaddr_in结构体,然后强制转换成sockaddr,作为参数传递给系统调用函数。
 

3,bind函数

// 用于将参数sockfd和addr绑定在一起
// 使sockfd这个文件描述符监听addr所描述的地址和端口号
int bind (int sockfd, const struct sockaddr * addr, socklen_t addrlen);
// 成功返回0,失败返回-1, 设置errno
参数解释
sockfd套接字描述符
addr套接字地址结构体
addrlen套接字地址结构体的长度

4,listen函数

// 用于将套接字从CLOSE状态转换为LISTEN状态
int listen(int sockfd, int backlog);
// 成功则返回0,失败返回-1,错误原因存于errno中
参数解释
sockfd套接字文件描述符
backlog指定允许在等待连接队列中排队的最大连接数量

5,accept函数

// 用于跟客户端建立连接,并返回客户端套接字
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 成功返回一个新的socket文件描述符,用于和客户端通信,
// 失败返回-1,设置errno
参数解释
sockfd监听套接字的文件描述符
addr指向用于存储客户端地址的结构体的指针
addrlen指向addr结构体大小的指针,用于接收客户端地址结构体的实际大小

6,connect函数

// 用于客户端建立tcp连接
int connect (int sockfd,struct sockaddr * addr,int addrlen);
// 成功则返回0,失败返回-1,错误原因存于errno中
参数解释
sockfd套接字文件描述符
addr指向目标服务器地址的结构体指针
addrlenaddr结构体的大小

7,recv与read函数

// 用于接受信息数据
int recv(int sockfd, void *buf, size_t len, int flags);
// 如果成功接收到数据,返回实际接收到的数据的字节数。
// 如果连接关闭,返回0。
// 如果发生错误,返回-1,并设置errno以指示错误的原因。
参数解释
sockfd套接字文件描述符
buf接收数据的缓冲区
len要接收的最大字节数
flags函数调用的标志

// 用于接受信息数据
ssize_t read(int fd, void *buf, size_t count);
// 如果成功接收到数据,返回实际接收到的字节数。
// 如果连接关闭,返回0。
// 如果发生错误,返回-1,并设置errno以指示错误的原因。
参数解释
fd文件描述符,可以是套接字文件描述符
buf接收数据的缓冲区
count要读取的最大字节数

8,send与write函数

// 用于发送信息数据
int send(int sockfd, const void *buf, size_t len, int flags);
// 如果成功发送数据,返回实际发送的字节数。
// 如果连接关闭,返回-1。
// 如果发生错误,返回-1,并设置errno以指示错误的原因。
参数解释
sockfd套接字文件描述符
buf是要发送的数据
len要发送的字节数
flags函数调用的标志

// 用于发送信息数据
ssize_t write(int fd, const void *buf, size_t count);
// 如果成功写入数据,返回实际写入的字节数。
// 如果发生错误,返回-1,并设置errno以指示错误的原因。
参数解释
fd文件描述符,可以是套接字文件描述符
buf要发送的数据
count要写入的字节数

9,close函数

// 关闭套接字 
int close(int fd)
// 若close成功则返回0,否则返回-1并置errno

三,socket通信的简单实现

参考链接:C++网络编程_zhihao_Guo的博客-CSDN博客

 1,服务端server.cpp

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

#define PORT 9990 // 端口号
#define SIZE 1024 // 定义的缓冲区大小

int main()
{
    // 创建套接字
    int server_socket = socket(AF_INET, SOCK_STREAM, 0);
    if (server_socket == -1)
    {
        perror("socket");
        return -1;
    }

    // 设置套接字属性
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;                // Internet地址族
    addr.sin_port = htons(PORT);              // 端口号
    addr.sin_addr.s_addr = htonl(INADDR_ANY); // IP地址

    // 绑定套接字到地址和端口
    int ret = bind(server_socket, (struct sockaddr *)&addr, sizeof(addr));
    if (ret == -1)
    {
        perror("bind");
        return -1;
    }

    // 将socket设置为监听模式
    ret = listen(server_socket, 5);
    if (ret == -1)
    {
        perror("listen");
        return -1;
    }

    struct sockaddr_in cliaddr;
    int addrlen = sizeof(cliaddr);
    std::cout << "正在等待客户端连接......" << std::endl;

    // 创建一个和客户端交流的套接字,用于接受客户端的连接
    int client_socket = accept(server_socket, (struct sockaddr *)&cliaddr, (socklen_t *)&addrlen);
    if (client_socket == -1)
    {
        perror("accept");
        return -1;
    }
    // 输出接收到的客户端的ip地址
    std::cout << "成功接收到一个客户端:" << inet_ntoa(cliaddr.sin_addr) << std::endl;

    char buf[SIZE]; // 接收数据的缓冲区
    while (1)
    {
        // 接收客户端数据
        int ret = recv(client_socket, buf, SIZE - 1, 0); // 数据长度
        if (ret == -1)
        {
            perror("recv");
            break;
        }
        if (ret == 0)
        {
            break;
        }
        buf[ret] = '\0'; // 断句
        for (int i = 0; i < ret; i++)
        {
            buf[i] = buf[i] + 'A' - 'a';
        }
        std::cout << "收到客户端发来的消息:" << buf << std::endl;

        // 向客户端发送数据
        send(client_socket, buf, ret, 0);
        // 结束标志
        if (strncmp(buf, "END", 3) == 0)
        {
            break;
        }
    }
    // 关闭socket连接释放资源资源
    close(client_socket);

    return 0;
}

2,客户端client.cpp

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

#define PORT 9990
#define SIZE 1024

int main()
{
	//创建和服务器连接套接字
	int client_socket = socket(AF_INET, SOCK_STREAM, 0);
	if(client_socket == -1)
	{
		perror("socket");
		return -1;
	}

	//设置套接字属性
	struct sockaddr_in addr;
	memset(&addr, 0, sizeof(addr));
	addr.sin_family = AF_INET;  				//设置Internet地址族
    addr.sin_port = htons(PORT);  				//设置端口号
    addr.sin_addr.s_addr = htonl(INADDR_ANY);   //设置IP地址
	inet_aton("127.0.0.1", &(addr.sin_addr));   //将点分十进制的字符串转换为网络字节序的二进制数

	//连接服务端
	int listen_socket =  connect(client_socket,  (struct sockaddr *)&addr, sizeof(addr));
	if(listen_socket == -1)
	{
		perror("connect");
		return -1;
	}
	std::cout<<"成功连接到一个服务端"<<std::endl;


	char buf[SIZE] = {0};
	while(1)        //向服务端发送数据,并接收服务端转换后的大写字母
	{
		std::cout<<"请输入小写的字符串:";
		std::cin>>buf;

		//向服务端发送数据
		send(client_socket, buf, strlen(buf),0);

		//接受服务端返回的数据
		int ret = recv(client_socket, buf, strlen(buf),0);
		if (ret == -1)
        {
            perror("recv");
            break;
        }
		std::cout<<"收到服务端返回的消息:"<<buf<<std::endl;

		if(strncmp(buf, "END", 3) == 0)//当输入end时客户端退出
		{
			break;
		}
	}
	//关闭socket连接释放资源资源
	close(listen_socket);

	return 0;
}

四,加入多线程的网络通信

1,多线程的利弊

一个client去连接连接一个server端,那么如果此时我们使用阻塞模式的话,如果只使用一个线程的话,此时第一个client连接过来了,调用了recv()函数,阻塞在等待消息这里,此时如果第二个客户端连接的话,因为程序一直在recv()这里等着,无法处理这个连接请求。那么如果我们使用多线程机制,对每个客户端都使用一个线程,就可以解决这个问题。可是如果有大量的客户连接的话,服务端就要创建大量的线程,大量的线程创建将会消耗许多资源。

2,pthread_create函数

// 线程的创建
int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*start_routine)(void*), void* arg);
// 成功返回0,否则返回错误码
参数解释
thread指向pthread_t类型的指针,用于接收新创建线程的标识符。pthread_t是一个不透明的线程标识符类型,可以用于操作线程的其他函数
attr指向pthread_attr_t类型的指针,用于设置新线程的属性。pthread_attr_t是一个线程属性类型,可以用于设置线程的堆栈大小、调度策略等。如果传递NULL,则使用默认线程属性
start_routine一个函数指针,指向线程的入口函数(Thread Entry Point)。该函数将在新线程中执行。它必须具有以下签名:void* functionName(void* arg),其中arg是一个指向线程入口函数的参数的指针,可以向线程传递参数
arg一个void类型的指针,用于向线程入口函数传递参数。可以将任意类型的指针转换为void类型进行传递

3,完整服务端代码

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <pthread.h>

#define PORT 9990           // 端口号
#define SIZE 1024           // 定义的数组大小
const int MAX_CLIENTS = 10; // 定义客户端最大连接数

int client_sockets[MAX_CLIENTS];
int num_clients = 0;

void *handle_client(void *client_socket_ptr)
{
    int client_socket = *(int *)client_socket_ptr;
    char buffer[SIZE];
    int ret;

    while ((ret = recv(client_socket, buffer, SIZE, 0)) > 0)
    {
        buffer[ret] = '\0'; // 断句
        for (int i = 0; i < ret; i++)
        {
            buffer[i] = buffer[i] + 'A' - 'a';
        }
        std::cout << "收到客户端发来的消息:" << buffer << std::endl;

        if (send(client_socket, buffer, strlen(buffer), 0) < 0)
        {
            std::cerr << "send" << std::endl;
            break;
        }
    }
    if (ret == 0)
    {
        std::cout << "客户端连接已断开" << std::endl;
        num_clients--;
    }
    else
    {
        std::cerr << "receive" << std::endl;
    }
    close(client_socket);//关闭socket
    pthread_exit(NULL);//关闭该线程
    // 没有指定去等待子线程,主线程也会等待子线程执行完毕后,才会最后结束程序
}

int main()
{
    // 创建套接字
    int server_socket = socket(AF_INET, SOCK_STREAM, 0);
    if (server_socket == -1)
    {
        std::cerr << "socket" << std::endl;
        return -1;
    }

    // 设置套接字属性
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;                // Internet地址族
    addr.sin_port = htons(PORT);              // 端口号
    addr.sin_addr.s_addr = htonl(INADDR_ANY); // IP地址

    // 绑定套接字到地址和端口
    int ret = bind(server_socket, (struct sockaddr *)&addr, sizeof(addr));
    if (ret == -1)
    {
        std::cerr << "bind" << std::endl;
        return -1;
    }

    // 将socket设置为监听模式
    ret = listen(server_socket, 5);
    if (ret == -1)
    {
        std::cerr << "listen" << std::endl;
        return -1;
    }

    struct sockaddr_in cliaddr;
    int addrlen = sizeof(cliaddr);
    std::cout << "正在等待客户端连接......" << std::endl;

    int client_socket;
    sockaddr_in client_addr;
    while (true)
    {
        socklen_t client_addr_len = sizeof(client_addr);
        client_socket = accept(server_socket, (sockaddr *)&client_addr, &client_addr_len);
        if (client_socket < 0)
        {
            std::cerr << "accept" << std::endl;
            return EXIT_FAILURE;
        }

        std::cout << "成功接收到一个客户端:" << inet_ntoa(cliaddr.sin_addr) << std::endl;

        if (num_clients < MAX_CLIENTS)
        {
            // 将客户端socket添加到数组中
            client_sockets[num_clients] = client_socket;

            // 创建新线程处理客户端消息
            pthread_t tid;
            if (pthread_create(&tid, NULL, handle_client, (void *)&client_socket) != 0)
            {
                std::cerr << "create thread" << std::endl;
                return EXIT_FAILURE;
            }

            num_clients++;
        }
        else
        {
            std::cerr << "连接失败,客户端连接数量已达上限......" << std::endl;
            close(client_socket);
        }
    }
    // 关闭socket连接释放资源资源
    close(server_socket);

    return 0;
}

五,阻塞与非阻塞I/O

参考连接:

阻塞与非阻塞式调用网络通信函数

使用fcntl()函数设置socket为阻塞态或非阻塞态Socket的非阻塞模式_socket 非阻塞设置的四种方法使用fcntl()函数设置socket为阻塞态或非阻塞态fcntl函数的用法总结

1,socket的两种工作模式

阻塞式I/O(Blocking I/O):在进行I/O操作的过程中,如果数据还没有准备好,则会等待数据准备好再进行后面的操作。在等待数据的过程中,线程阻塞,无法进行其他操作。

非阻塞式I/O(Non-blocking I/O):与阻塞式I/O不同,非阻塞式I/O不会在等待数据准备好时阻塞线程。如果数据没有准备好,它会立即返回一个错误代码。线程可以在等待数据准备好的同时继续执行其他操作。

通常情况下:将Socket设置为非阻塞模式更常见和有意义的是在服务器端,而不是在客户端。

在服务器端,当使用多线程或多进程模型时,将Socket设置为非阻塞模式可以提高并发性能。这允许服务器能够同时处理多个客户端连接请求,而不会被阻塞在单个连接的IO操作上。在非阻塞模式下,服务器可以通过调用accept()接受客户端连接请求,如果没有新的连接请求到达,accept()立即返回,不会阻塞等待。这样服务器可以继续处理其他事务或者响应其他客户端的请求。

另一方面,在客户端,将Socket设置为非阻塞模式可能没有那么常见,因为通常客户端更关注单个连接的IO操作。不过,如果客户端在处理多个Socket连接,或者希望在等待数据到达时能同时进行其他操作,可以考虑将其设置为非阻塞模式。

需要注意的是,在非阻塞模式下,IO操作将返回实际读取/写入的字节数或错误码,而不会一直等待直到操作完成。因此,在非阻塞模式下,需要适当地处理返回的值和错误码,进行合适的重试或者等待。同时,也需要使用适当的事件驱动机制(如select、poll、epoll等)来监视和处理Socket的可读、可写事件。

2,设置socket非阻塞的方法

socket在创建的时候默认是阻塞的,将socket设置为非阻塞的有以下几种方法

(1)使用fcntl函数

fcntl函数是一个系统调用,它可以用于对文件描述符进行各种控制操作,包括设置非阻塞。

#include <fcntl.h>
//...
int fd = socket(AF_INET, SOCK_STREAM, 0);
int flag = fcntl(fd,F_GETFL,0);//获取文件fd当前的状态
//int flag = fcntl(fd,F_GETFL);//不用第3个参数也可以
if(flag<0)
{
    perror("fcntl F_GETFL fail"); 
    close(fd);  
}
if (fcntl(fd, F_SETFL, flag | O_NONBLOCK) < 0)//设置为非阻塞态
{  
    perror("fcntl F_SETFL fail"); 
    close(fd);
}
// 服务端绑定、监听等;客户端连接等
//...

(2)使用ioctl函数

ioctl函数也可以用来对文件描述符进行控制操作,包括设置非阻塞。

#include <sys/ioctl.h>
//...
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 设置非阻塞
int flags = 1;                     //将flags设置为非零值表示启用非阻塞模式
ioctl(sockfd, FIONBIO, &flags);    //FIONBIO是一个常量,表示设置或获取非阻塞I/O
// 服务端绑定、监听等;客户端连接等
//...

(3)创建socket时设置

int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);

(4)accept4

int accept4(int sockfd, struct sockaddr *addr,socklen_t *addrlen, int flags);

六,I/O多路复用

参考链接:

select服务端代码实现

一文看懂IO多路复用 - 知乎

IO多路复用select函数详解

 1,目前的IO多路复用方案

解决方案总览

Linux: select、poll、epoll

MacOS/FreeBSD: kqueue

Windows/Solaris: IOCP

常见软件的IO多路复用方案

redis: Linux下 epoll(level-triggered),没有epoll用select

nginx: Linux下 epoll(edge-triggered),没有epoll用select

2,select相关函数详解

(1)select

// 用于在一组文件描述符上等待可读、可写或异常事件发生。
// 它使用三个文件描述符集合(readfds、writefds和exceptfds)指定要关注的事件类型。
// nfds参数表示最大的文件描述符值加1。timeout参数用于指定超时时间。
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
// 如果成功,返回就绪事件的文件描述符数。
// 如果超时,返回0。
// 如果出错,返回-1,并设置errno以指示错误的原因。
参数解释
nfds最大的文件描述符加1
readfds关注可读事件的文件描述符集合
writefds关注可写事件的文件描述符集合
exceptfds关注异常事件的文件描述符集合
timeout超时时间,可以是NULL表示阻塞,或者指向struct timeval结构表示具体的超时时间

 (2)FD_ZERO

用来清空文件描述符组。每次调用select前都需要清空一次

int FD_ZERO(fd_set *fdset);  //将一个 fd_set类型变量的所有位都设为0

fd_set writefds;
FD_ZERO(&writefds)

(3)FD_SET、FD_CLR

添加一个文件描述符到组中,FD_CLR对应将一个文件描述符移出组中

int FD_SET(int fd, fd_set *fdset);  //将变量的某个位置位(置1)
int FD_CLR(int fd, fd_set *fdset);  //清除变量某个位

FD_SET(fd, &writefds);
FD_CLR(fd, &writefds);

(4)FD_ISSET

检测一个文件描述符是否在组中,我们用这个来检测一次select调用之后有哪些文件描述符可以进行IO操作

int FD_ISSET(int fd, fd_set *fdset); //测试某个位是否被置位

if (FD_ISSET(fd, &readfds)){
  /* fd可读 */
}

(5)FS_SETSIZE

select可同时监听的文件描述符数量是通过FS_SETSIZE来限制的,在Linux系统中,该值为1024,当然我们可以增大这个值,但随着监听的文件描述符数量增加,select的效率会降低。

(6)完整服务端代码

#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <ctype.h>

#define SERV_PORT 8888

int main(int argc, char *argv[])
{
    int listenfd, connfd;
    char buf[BUFSIZ], str[INET_ADDRSTRLEN];

    struct sockaddr_in clie_addr, serv_addr;
    socklen_t clie_addr_len;

    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // SO_REUSEADDR允许重用本地地址端口

    bzero(&serv_addr, sizeof(serv_addr));

    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(SERV_PORT);

    bind(listenfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
    listen(listenfd, 128);

    fd_set rset, allset;
    int ret, maxfd = 0;
    maxfd = listenfd;
    FD_ZERO(&allset); // 集合全部设为0
    FD_SET(listenfd, &allset);

    while (1)
    {

        rset = allset;
        ret = select(maxfd + 1, &rset, NULL, NULL, NULL); // 返回有事件发生的个数
        if (FD_ISSET(listenfd, &rset))
        {
            clie_addr_len = sizeof(clie_addr);
            connfd = accept(listenfd, (struct sockaddr *)&clie_addr, &clie_addr_len);

            std::cout<<"received from "<<inet_ntop(AF_INET, &clie_addr.sin_addr, str, sizeof(str))
            <<" at PORT "<<ntohs(clie_addr.sin_port)<<std::endl;

            FD_SET(connfd, &allset); // 将一个集合中的事件“加入”
            if (maxfd < connfd)
                maxfd = connfd;
            if (ret == 1)
                continue; // 说明select只返回一个,并且只是listenfd,无需后续执行
        }
        int n = 0; // read读到的字节数
        for (int i = listenfd + 1; i < maxfd + 1; ++i)
        {
            if (FD_ISSET(i, &rset)) // 判断文件描述符是否在集合中
            {
                n = read(i, buf, sizeof(buf));
                if (n == 0)
                {
                    close(i);
                    FD_CLR(i, &allset); // 将一个集合中的事件“拿走”
                }
                else if (n == -1)
                {
                }
                else
                    for (int j = 0; j < n; ++j)
                        buf[j] = toupper(buf[j]);
                write(i, buf, n);
                //write(STDOUT_FILENO, buf, n);//向屏幕输出
                std::cout<<buf<<std::endl;
            }
        }
    }

    close(listenfd);
    return 0;
}

3,poll相关函数详解

(1)poll

// 用于等待一组文件描述符上的事件发生。
// 它通过struct pollfd结构指定要监视的文件描述符和关注的事件类型。
// 函数将在事件就绪或超时时返回,并将就绪的事件存储在数组fds中。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// 如果成功,返回就绪事件的数量。
// 如果超时,返回0。
// 如果出错,返回-1,并设置errno以指示错误的原因。
参数解释
fds一个struct pollfd类型的数组,每个元素指定要监视的文件描述符、感兴趣的事件和返回的事件
nfds数组中的文件描述符数量
timeout超时时间,单位是毫秒

用于监视并等待多个文件描述符的属性变化,poll模型是基于select最大文件描述符限制提出的,跟select一样,只是将select使用的三个基于位的文件描述符改为使用一个数组的形式,对于各种可能的事件进行了一个包装。使用poll函数时,没有明确的限制,可以监控的文件描述符数目取决于系统的可用资源。poll函数将所需要监听的fd集合传递给内核,内核直接检测,没有遍历的过程,效率相对较高。适用于数据量大情况

(2)pollfd结构体

struct pollfd {
    int fd;        //被监视的文件描述符
    short events;  //指定监测fd的事件(输入、输出、错误),每一个事件有多个取值,如下:
    short revents; //文件描述符的操作结果事件,内核在调用返回时设置这个域
    };

events 域中请求的任何事件都可能在 revents 域中返回,每个结构体的 events 域是由用户来设置,告诉内核我们关注的是什么,而 revents 域是返回时内核设置的,以说明对该描述符发生了什么事件。

(3)完整服务端代码

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

using namespace std;

#define MAX_CLIENTS 1024
#define BUFFER_SIZE 1024 // 缓冲区大小

int main()
{
    // 创建server_fd
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1)
    {
        perror("Failed to create server_sock");
        return -1;
    }

    // 服务器网络地址结构体
    struct sockaddr_in server_addr = {0};
    server_addr.sin_family = AF_INET; // IPV4
    server_addr.sin_port = htons(8888);
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // INADDR_ANY 为服务器地址为本地所有网卡

    // 绑定服务器
    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
    {
        perror("Failed to bind socket");
        return -1;
    }

    // 进行监听
    if (listen(server_fd, MAX_CLIENTS) == -1)
    {
        cerr << "Failed to listen client" << endl;
        return -1;
    }

    cout << "Server listening on: "
         << "127.0.0.1: 8888" << endl;

    // poll创建
    struct pollfd fds[MAX_CLIENTS + 1]; // pollfd数组,包括服务器套接字和所有客户端套接字
    memset(fds, 0, sizeof(fds));        // 初始化
    fds[0].fd = server_fd;              // 设置第一个元素为服务器套接字
    fds[0].events = POLLIN;             // 监听POLLIN事件,表示有数据可读

    int clients_num = 0; // 客户端数量

    while (true)
    {
        // 调用poll函数,等待事件
        if (poll(fds, clients_num + 1, -1) < 0)
        { // nfds:需要监控的文件描述符的数量, -1表示无限等待,直到事件发送
            perror("poll error");
            break;
        }

        // 如果服务器套接字上有POLLIN事件,说明有新的客户端连接
        if (fds[0].revents & POLLIN)
        { //& 与 两个位都为1时,结果才为1
            if (clients_num == MAX_CLIENTS)
            {
                // 客户端数量超限
                cerr << "Too many clients" << endl;
                continue;
            }

            // 接受客户端请求
            struct sockaddr_in client_addr = {0};
            socklen_t client_len = sizeof(client_addr);
            int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
            if (client_fd == -1)
            {
                perror("Error accepting new client");
            }
            else
            {
                clients_num++;
                // 将新的客户端套接字添加到fds数组中
                fds[clients_num].fd = client_fd;
                fds[clients_num].events = POLLIN;

                printf("Establish a new connection: %s : %d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
            }
        }

        // 遍历已连接的所有客户端套接字
        for (int i = 1; i <= clients_num; i++)
        {
            if (fds[i].revents & POLLIN)
            {
                // 服务器端发送和接收数据
                char send_buffer[BUFFER_SIZE] = "服务器收到确认!";
                char recv_buffer[BUFFER_SIZE];

                int recv_size = recv(fds[i].fd, recv_buffer, BUFFER_SIZE, 0);
                if (recv_size == -1)
                {
                    cerr << "Error receiving from client" << endl;
                }
                else if (recv_size == 0)
                {
                    // 客户端断开连接
                    printf("Client disconnected: client_fd: %d\n", fds[i].fd);

                    close(fds[i].fd);          // 关闭套接字
                    fds[i] = fds[clients_num]; // 将最后一个元素移动到当前位置,当前套接字删除
                    memset(&fds[clients_num], 0, sizeof(fds[clients_num]));
                    clients_num--;
                }
                else
                {
                    // 接收数据处理
                    cout << "Received message:" << endl
                         << recv_buffer << endl;
                    // 发送确认数据处理
                    if (send(fds[i].fd, send_buffer, strlen(send_buffer), 0) == -1)
                    {
                        cerr << "Failed to sent data to client: client_fd: " << fds[i].fd << endl;
                    }
                }
            }
        }
    }
    close(server_fd);
    return 0;
}

4,epoll相关函数详解

(1)epoll_create

// 创建一个epoll实例,返回一个epoll文件描述符,可用于后续的epoll操作。
int epoll_create(int size);
// 如果成功,返回epoll文件描述符。
// 如果失败,返回-1,并设置errno以指示错误的原因。
参数解释
size在内核中创建的epoll实例的大小

 需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的。在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

(2)epoll_ctl

// 用于向epoll实例中添加、修改或删除感兴趣的事件。
// 它将文件描述符fd与epoll实例(epfd)关联,并指定感兴趣的事件类型和相关的数据。
// 操作类型op决定了具体的操作,例如添加、修改或删除。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
// 如果成功,返回0。
// 如果失败,返回-1,并设置errno以指示错误的原因。
参数解释
epfd已创建的epoll文件描述符
op操作类型,可以是EPOLL_CTL_ADDEPOLL_CTL_MODEPOLL_CTL_DEL,分别用于添加、修改或删除事件
fd关联的文件描述符
event是一个struct epoll_event类型的指针,用于指定感兴趣的事件和关联的数据

epoll的事件注册函数,epoll_ctl向epoll对象中添加、修改或者删除事件,返回0表示成功,否则返回–1,需要根据errno错误码判断错误类型。它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。

op参数的宏解释
EPOLL_CTL_ADD注册新的fd到epfd中
EPOLL_CTL_MOD修改已经注册的fd的监听事件
EPOLL_CTL_DEL从epfd中删除一个fd
events参数的宏解释
EPOLLIN表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
EPOLLOUT表示对应的文件描述符可以写
EPOLLPRI表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
EPOLLERR表示对应的文件描述符发生错误
EPOLLHUP表示对应的文件描述符被挂断
EPOLLET将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

(3)epoll_wait

// 用于阻塞的等待可以执行IO操作的文件描述符直到超时
​int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
// 返回需要处理的事件数目,
// 如返回0表示已超时,
// 如返回–1,则表示出现错误,需要检查 errno错误码判断错误类型。
参数解释
epfdepoll的描述符
events用来从内核得到事件的集合,,epoll将会把发生的事件复制到 events数组中(events不可以是空指针,内核只负责把数据复制到这个 events数组中,不会去帮助我们在用户态中分配内存)
maxevents表示本次可以返回的最大事件数目,通常 maxevents参数与预分配的events数组的大小是相等的。告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()
timeouttimeout表示在没有检测到事件发生时最多等待的时间(单位为毫秒),如果 timeout为0,则表示 epoll_wait在 rdllist链表中为空,立刻返回,-1将不确定,也有说法说是永久阻塞

(4)完整服务端代码

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

using namespace std;

#define MAX_CLIENTS 128
#define BUFFER_SIZE 1024

int main()
{
    // 创建服务器socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1)
    {
        perror("Failed to create socket");
        return -1;
    }

    // 绑定服务器
    struct sockaddr_in server_addr = {0};
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8888);
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
    {
        cerr << "Failed to bind server_fd" << endl;
        close(server_fd);
        return -1;
    }

    // 监听客户端
    if (listen(server_fd, MAX_CLIENTS) == -1)
    {
        perror("listen error");
        return -1;
    }

    cout << "Server started, listening on port: " << 8888 << endl;

    // 创建epoll实例
    // 变量ev是用于控制添加或修改一个文件描述符的事件类型和数据,events用于存储发生的事件类型和数据。
    struct epoll_event ev, events[MAX_CLIENTS];
    int epoll_fd = epoll_create(10); // 动态大小的内部事件表,可以初始化为0
    if (epoll_fd == -1)
    {
        perror("epoll_create error");
        return -1;
    }

    // 添加监听套接字到epoll
    ev.data.fd = server_fd;
    ev.events = EPOLLIN; // 监听套接字的读事件
    // 往 epoll 实例中添加一个文件描述符,EPOLL_CTL_ADD是 epoll 操作的一种类型,表示要添加或修改某个文件描述符的事件,
    // server_fd是要添加到 epoll 实例中的目标文件描述符,&ev 是要添加的事件数据。
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev) == -1)
    {
        perror("epoll_ctl error");
        return -1;
    }

    while (true)
    {
        // 等待事件,-1 表示阻塞等待,epoll_fd 是 epoll 实例的文件,events 是一个 struct epoll_event 类型的数组,用于接收已经触发的事件。
        int events_count = epoll_wait(epoll_fd, events, MAX_CLIENTS, -1); // epoll_wait 函数的返回值是一个整数,表示有多少个事件已经准备就绪并被返回
        if (events_count == -1)
        {
            perror("epoll_wait error");
            break;
        }

        // 遍历已经就绪的套接字
        for (int i = 0; i < events_count; i++)
        {
            if (events[i].data.fd == server_fd) // 若为监听套接字的事件
            {
                struct sockaddr_in client_addr = {0};
                socklen_t client_len = sizeof(client_addr);
                int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
                if (client_fd == -1)
                {
                    perror("accept error");
                    break;
                }

                // 添加连接套接字到epoll
                ev.data.fd = client_fd;
                ev.events = EPOLLIN; // 连接套接字的读事件
                if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev) == -1)
                {
                    perror("epoll_ctl client_fd error");
                    break;
                }

                printf("Client from: %s: %d connected\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
            }
            else if (events[i].events & EPOLLIN) // 若为连接套接字的读事件
            {
                char recv_buffer[BUFFER_SIZE] = {0};
                char send_buffer[BUFFER_SIZE] = "server is recved";//有问题

                // 接收数据
                int recv_size = recv(events[i].data.fd, recv_buffer, sizeof(recv_buffer), 0);
                if (recv_size == -1)
                {
                    perror("recv error");
                    break;
                }
                else if (recv_size == 0) // 断开情况下,删除该套接字
                {
                    if (epoll_ctl(epoll_fd, EPOLL_CTL_DEL, events[i].data.fd, nullptr) == -1)
                    {
                        perror("epoll_ctl del erroe");
                        break;
                    }
                    // 在删除文件描述符之后,也不需要将 events[i] 结构体初始化,
                    // 因为它仅用于保存上一次触发的事件相关信息,
                    // 而已经删除的文件描述符不可能再次触发事件。
                    close(events[i].data.fd);
                    printf("Connection closed: socket= %d", events[i].data.fd);
                }
                else
                {
                    // 判断客户端是否退出
                    if (strcmp(recv_buffer, "exit") == 0)
                    {
                        std::cout << "收到客户端指令,实现客户端退出: socket= " << events[i].data.fd << std::endl;

                        if (epoll_ctl(epoll_fd, EPOLL_CTL_DEL, events[i].data.fd, nullptr) == -1)
                        { // 删除该套接字
                            perror("epoll_ctl del erroe");
                            break;
                        }
                        close(events[i].data.fd);
                        printf("Connection closed: socket= %d\n", events[i].data.fd);
                        continue;
                    }

                    printf("Received data: %s\n", recv_buffer);
                    // 发送数据
                    if (send(events[i].data.fd, send_buffer, strlen(send_buffer), 0) == -1)
                    {
                        cerr << "Failed to send data to client" << endl;
                        break;
                    }
                }
            }
        }
    }

    // 删除 epoll 监听的文件描述符
    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, server_fd, nullptr);
    close(server_fd);
    close(epoll_fd);
    return 0;
}

5,select、poll、epoll的区别

(1)接口和数据结构

  • select使用fd_set数据结构来存储文件描述符集合。需要在函数调用时传递readfds、writefds和exceptfds参数来指定要监听的文件描述符集合及对应的事件类型。
  • poll使用pollfd数据结构来存储文件描述符集合,并通过传递pollfd数组参数来指定要监听的文件描述符集合。
  • epoll使用epollevent数据结构存储文件描述符集合,并通过epollctl函数添加到内核事件表中。


(2)文件描述符数量限制

  • select和poll都有文件描述符数量的限制,通常最多可监听1024个文件描述符(取决于文件描述符集合的位图长度)。
  • epoll没有明确的文件描述符数量限制,能够支持非常大的文件描述符数量。


(3)效率和性能

  • select和poll在查找有事件发生的文件描述符时需要遍历整个文件描述符集合,性能较差。
  • epoll使用回调机制,将文件描述符添加到内核事件表中,并通过epoll_wait等待内核事件表中有事件发生的文件描述符。在文件描述符数量较多时,epoll相比select和poll拥有更高的性能。


(4)I/O事件触发模式

  • select和poll的触发模式只有水平触发(LT,Level Triggered)。
  • epoll支持水平触发(LT)和边缘触发(ET,Edge Triggered)两种模式,还可以使用ONE SHOT模式(一个事件只触发一次)。


(5)平台支持

  • select和poll在多数操作系统上都有支持,并且是跨平台的。
  • epoll是Linux特有的,只能在Linux操作系统上使用。

(6)总结

  • select和poll是跨平台的多路复用机制,适用于小规模的并发连接。
  • epoll是Linux特有的多路复用机制,适用于大规模的并发连接,具有更好的性能和灵活的事件触发模式支持
     

这是一门linuxc++通讯架构实战课程,针对c/c++语言已经掌握的很熟并希望进一步深造以将来用c++linux下从事网络通讯领域/网络服务器的开发和架构工作。这门课程学习难度颇高但也有着极其优渥的薪水(最少30K月薪,最高可达60-80K月薪),这门课程,会先从nginx源码的分析和讲解开始,逐步开始书写属于自己的高性能服务器框架代码,完善个人代码库,这些,将会是您日后能取得高薪的重要筹码。本课程原计划带着大家逐行写代码,但因为代码实在过于复杂和精细,带着写代码可能会造成每节课至少要4~5小时的超长时间,所以老师会在课前先写好代码,主要的时间花费在逐行讲解这些代码上,这一点望同学们周知。如果你觉得非要老师领着写代码才行的话,老师会觉得你当前可能学习本门课程会比较吃力,请不要购买本课程,以免听不懂课程并给老师差评,差评也会非常影响老师课程的销售并造成其他同学的误解。 这门课程要求您具备下面的技能:(1)对c/c++语言掌握的非常熟练,语言本身已经不是继续学习的障碍,并不要求您一定熟悉网络或者linux;(2)对网络通讯架构领域有兴趣、勇于挑战这个高难度的开发领域并期望用大量的付出换取高薪;在这门课程中,实现了一个完整的项目,其中包括通讯框架和业务逻辑框架,浓缩总结起来包括如下几点:(1)项目本身是一个极完整的多线程高并发的服务器程序;(2)按照包头包体格式正确的接收客户端发送过来的数据包, 完美解决收包时的数据粘包问题;(3)根据收到的包的不同来执行不同的业务处理逻辑;(4)把业务处理产生的结果数据包正确返回给客户端;本项目用到的主要开发技术和特色包括:(1)epoll高并发通讯技术,用到的触发模式是epoll中的水平触发模式【LT】;(2)自己写了一套线程池来处理业务逻辑,调用适当的业务逻辑处理函数处理业务并返回给客户端处理结果;(3)线程之间的同步技术包括互斥量,信号量等等;(4)连接池中连接的延迟回收技术,这是整个项目中的精华技术,极大程度上消除诸多导致服务器程序工作不稳定的因素;(5)专门处理数据发送的一整套数据发送逻辑以及对应的发送线程;(6)其他次要技术,包括信号、日志打印、fork()子进程、守护进程等等;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值