Linux高性能服务器编程|阅读笔记:第8章 - 高性能服务器程序框架

举报
海轰Pro 发表于 2023/05/27 21:52:36 2023/05/27
【摘要】 简介Hello!非常感谢您阅读海轰的文章,倘若文中有错误的地方,欢迎您指出~ ଘ(੭ˊᵕˋ)੭昵称:海轰标签:程序猿|C++选手|学生简介:因C语言结识编程,随后转入计算机专业,获得过国家奖学金,有幸在竞赛中拿过一些国奖、省奖…已保研学习经验:扎实基础 + 多做笔记 + 多敲代码 + 多思考 + 学好英语! 唯有努力💪 本文仅记录自己感兴趣的内容 8.1 服务器模型 8.1.1 C/S模...

简介

Hello!
非常感谢您阅读海轰的文章,倘若文中有错误的地方,欢迎您指出~
 
ଘ(੭ˊᵕˋ)੭
昵称:海轰
标签:程序猿|C++选手|学生
简介:因C语言结识编程,随后转入计算机专业,获得过国家奖学金,有幸在竞赛中拿过一些国奖、省奖…已保研
学习经验:扎实基础 + 多做笔记 + 多敲代码 + 多思考 + 学好英语!
 
唯有努力💪
 
本文仅记录自己感兴趣的内容

8.1 服务器模型

8.1.1 C/S模型

8.1.2 P2P模型

P2P:peer to peer 点对点

8.2 服务器编程框架

基础框架:

I/O处理单元是服务器管理客户连接的模块。它通常要完成以下工作:

  • 等待并接受新的客户连接,接收客户数据,将服务器响应数据返回给客户端。但是,数据的收发不一定在I/O处理单元中执行,也可能在逻辑单元中执行,具体在何处执行取决于事件处理模式(见后文)。
  • 对于一个服务器机群来说,I/O处理单元是一个专门的接入服务器。它实现负载均衡,从所有逻辑服务器中选取负荷最小的一台来为新客户服务。

一个逻辑单元通常是一个进程或线程。它分析并处理客户数据,然后将结果传递给I/O处理单元或者直接发送给客户端(具体使用哪种方式取决于事件处理模式)。对服务器机群而言,一个逻辑单元本身就是一台逻辑服务器。服务器通常拥有多个逻辑单元,以实现对多个客户任务的并行处理。

8.3 I/O模型

阻塞和非阻塞的概念能应用于所有文件描述符,不仅仅是socket,我们称阻塞的文件描述符尾阻塞I/O,称非阻塞的文件描述符为非阻塞I/O

针对阻塞I/O执行的系统调用可能因为无法立即完成而被操作系统挂起,直到等待的事件发生为止。比如,客户端通过connect向服务器发起连接时,connect将首先发送同步报文段给服务器,然后等待服务器返回确认报文段。如果服务器的确认报文段没有立即到达客户端,则connect调用将被挂起,直到客户端收到确认报文段并唤醒connect调用。socket的基础API中,可能被阻塞的系统调用包括accept、send、recv和connect。

针对非阻塞I/O执行的系统调用则总是立即返回,而不管事件是否已经发生。如果事件没有立即发生,这些系统调用就返回-1,和出错的情况一样。

此时我们必须根据ermo来区分这两种情况。对accept、send和recv而言,事件未发生时errno通常被设置成EAGAIN(意为“再来一次”) 或者EWOULDBLOCK(意为“期望阻塞”),对于connect来说,errno则被设置为EINPROGRESS(意为“在处理中”)

很显然,我们只有在事件已经发生的情况下操作非阻塞I/O(读、写等),才能提高程序的效率。因此,非阻塞I/O通常要和其他VO通知机制一起使用,比如I/O复用和SIGIO信号。

  • I/O复用是最常使用的I/O通知机制。它指的是,应用程序通过I/O复用函数向内核注册一组事件,内核通过I/O复用函数把其中就绪的事件通知给应用程序。Liux上常用的I/O复用函数是select、poll和epoll_wait。需要指出的是,I/O复用函数本身是阻塞的,它们能提高程序效率的原因在于它们具有同时监听多个I/O事件的能力。
  • SIGIO信号也可以用来报告I/O事件。我们可以为一个目标文件描述符指定宿主进程,那么被指定的宿主进程将捕获到SIGIO信号。这样,当目标文件描述符上有事件发生时,SGIO信号的信号处理函数将被触发,我们也就可以在该信号处理函数中对目标文件描述符执行非阻塞I/O操作了。

8.4 两种高效的事件处理模式

服务器程序通常需要处理三类事件:I/O事件、信号及定时事件

同步I/O模型通常用于实现Reactor模式,异步I/O模型则用于实现Proactor模式

8.4.1 Reactor模式

Reactor是这样一种模式

  • 它要求主线程(I/O处理单元)只负责监听文件描述上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元)。除此之外,主线程不做任何其他实质性的工作。
  • 读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。

Reactor模式的工作流程:

8.4.2 Proactor模式

与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑

Proactor工作流程:

8.5 两种高效的并发模式

8.5.1 半同步/半异步模式

首先,半同步/半异步模式中的“同步”和“异步”与前面讨论的I/O模型中的“同步”和“异步”是完全不同的概念。

  • 在I/O模型中,“同步”和“异步”区分的是内核向应用程序通知的是何种I/O事件(是就绪事件还是完成事件),以及该由谁来完成/O读写(是应用程序还是内核)。
  • 在并发模式中,“同步”指的是程序完全按照代码序列的顺序执行:“异步” 指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等

按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。显然,异步线程的执行效率高,实时性强,这是很多嵌入式程序采用的模型。但编写以异步方式执行的程序相对复杂,难于调试和扩展,而且不适合于大量的并发。而同步线程则相反, 它虽然效率相对较低,实时性较差,但逻辑简单。

因此,对于像服务器这种既要求较好的实时性,又要求能同时处理多个客户请求的应用程序,我们就应该同时使用同步线程和异步线程来实现,即采用半同步/半异步模式来实现。

半同步/半异步模式中

  • 同步线程用于处理客户逻辑,相当于图8-4中的逻辑单元
  • 异步线程用于处理I/O事件,相当于图8-4中的I/O处理单元。
    • 异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中。
    • 请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。
    • 具体选择哪个工作线程来为新的客户请求服务,则取决于请求队列的设计。
    • 比如最简单的轮流选取工作线程的Round Robin算法
    • 也可以通过条件变量或信号量来随机地选择一个工作线程。

半同步/半异步模式的工作流程

在服务器程序中,如果结合考虑两种事件处理模式和几种I/O模型,则半同步/半异步模式就存在多种变体。

其中有一种变体称为半同步/半反应堆(half-sync/half-reactive)模式,如图8-10所示:

图8-10中,异步线程只有一个,由主线程来充当。它负责监听所有socket上的事件。 如果监听socket上有可读事件发生,即有新的连接请求到来,主线程就接受之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件。如果连接socket上有读 写事件发生,即有新的客户请求到来或有数据要发送至客户端,主线程就将该连接socket插 入请求队列中。所有工作线程都睡眠在请求队列上,当有任务到来时,它们将通过竞争(比如申请互斥锁)获得任务的接管权。这种竞争机制使得只有空闲的工作线程才有机会来处理新任务,这是很合理的。

图8-10中,主线程插入请求队列中的任务是就绪的连接socket。这说明该图所示的半同步/半反应堆模式采用的事件处理模式是Reactor模式:

  • 它要求工作线程自己从socket上读取客户请求和往socket写入服务器应答。这就是该模式的名称中“half-reactive”的含义。
  • 实际上,半同步/半反应堆模式也可以使用模拟的Proactor事件处理模式,即由主线程来完成 数据的读写。
    • 在这种情况下,主线程一般会将应用程序数据、任务类型等信息封装为一个任 务对象,然后将其(或者指向该任务对象的一个指针)插人请求队列。
    • 工作线程从请求队列 中取得任务对象之后,即可直接处理之,而无须执行读写操作了。

半同步/半反应堆模式存在如下缺点:

  • 主线程和工作线程共享请求队列。主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费CPU时间。
  • 每个工作线程在同一时间只能处理一个客户请求。如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的响应速度将越来越慢。如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量CPU时间。

图8-11描述了一种相对高效的半同步/半异步模式,它的每个工作线程都能同时处理多个客户连接

图8-I1中,主线程只管理监听socket,连接socket由工作线程来管理。当有新的连接到来时,主线程就接受之并将新返回的连接socket派发给某个工作线程,此后该新socket上的任何I/O操作都由被选中的工作线程来处理,直到客户关闭连接。主线程向工作线程派发socket的最简单的方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新sockct上的读写事件注册到自己的epoll内核事件表中。

可见,图8-11中,每个线程(主线程和工作线程)都维持自己的事件循环,它们各自独立地监听不同的事件。因此,在这种高效的半同步/半异步模式中,每个线程都工作在异步模式,所以它并非严格意义上的半同步/半异步模式。

8.5.2 领导者/追随者模式

领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件
的一种模式。

  • 在任意时间点,程序都仅有一个领导者线程,它负责监听I/O事件。而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者。
  • 当前的领导者如果检测到I/O事件,首先要从线程池中推选出新的领导者线程,然后处理I/O事件。此时,新的领导者等待新的I/O事件,而原来的领导者则处理I/O事件,二者实现了并发。

领导者/追随者模式包含如下几个组件:

  • 句柄集(HandleSet)
  • 线程集 (ThreadSet)
  • 事件处理器(EventHandler)
  • 具体的事件处理器(ConcreteEventHandler)

1.句柄集

句柄(Handle)用于表示I/O资源,在Linux下通常就是一个文件描述符。句柄集管理众多句柄,它使用wait_for_event方法来监听这些句柄上的I/O事件,并将其中的就绪事件通知给领导者线程。领导者则调用绑定到Handle上的事件处理器来处理事件。领导者将Handle和事件处理器绑定是通过调用句柄集中的register_handle方法实现的。

2.线程集

这个组件是所有工作线程(包括领导者线程和追随者线程)的管理者。

它负责各线程之间的同步,以及新领导者线程的推选。线程集中的线程在任一时间必处于如下三种状态之一:

  • Leader:线程当前处于领导者身份,负责等待句柄集上的I/O事件。
  • Processing:线程正在处理事件。领导者检测到I/O事件之后,可以转移到Processing 状态来处理该事件,并调用promote_new_leader方法推选新的领导者;也可以指定其他追随者来处理事件(Event Handoff),此时领导者的地位不变。当处于Processing 状态的线程处理完事件之后,如果当前线程集中没有领导者,则它将成为新的领导者,否则它就直接转变为追随者。
  • Follower:线程当前处于追随者身份,通过调用线程集的join方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务。

三种状态的转换关系:

3.事件处理器和具体的事件处理器

事件处理器通常包含一个或多个回调函数handle_event。。这些回调函数用于处理事件对应的业务逻辑。事件处理器在使用前需要被绑定到某个句柄上,当该句柄上有事件发生时,领导者就执行与之绑定的事件处理器中的回调函数。具体的事件处理器是事件处理器的派生类。它们必须重新实现基类的handle_event方法,以处理特定的任务。


领导者/追随者模式的工作流程

由于领导者线程自己监听I/O事件并处理客户请求,因而领导者/追随者模式不需要在线程之间传递任何额外的数据,也无须像半同步/半反应堆模式那样在线程之间同步对 请求队列的访问。但领导者/追随者的一个明显缺点是仅支持一个事件源集合,因此也无法像图8-11所示的那样,让每个工作线程独立地管理多个客户连接。

8.6 有限状态机

逻辑单元内部的一种高效编程方法:有限状态机

状态独立的有限状态机

带状态转移的有限状态机


小实验:http请求的读取和分析(状态机实现)

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>

#define BUFFER_SIZE 4096

// 主状态机的两种可能状态,分别表示:当前正在分析请求行、当前正在分析头部字段
enum CHECK_STATE { CHECK_STATE_REQUESTLINE = 0, CHECK_STATE_HEADER, CHECK_STATE_CONTENT };
// 从状态机的三种可能状态,即行的读取状态,分别表示:读取到一个完整的行、行出错、行数据尚且不完整
enum LINE_STATUS { LINE_OK = 0, LINE_BAD, LINE_OPEN };
// 服务器处理http请求的结果
// NO_REQUEST:请求不完整,需要继续读取客户数据
// GET_REQUEST:获得了一个完整的客户请求
// BAD_REQUEST:客户请求有语法错误
// FORBIDDEN_REQUEST:客户对资源没有足够的访问权限
// INTERNAL_ERROR:服务器内部错误
// CLOSED_CONNECTION:服务器已经关闭连接了
enum HTTP_CODE { NO_REQUEST, GET_REQUEST, BAD_REQUEST, FORBIDDEN_REQUEST, INTERNAL_ERROR, CLOSED_CONNECTION };
static const char* szret[] = { "I get a correct result\n", "Something wrong\n" };

// 从状态机,用于解析一行内容
LINE_STATUS parse_line( char* buffer, int& checked_index, int& read_index )
{
    char temp;
    // check_index指向buffer中当前正在分析的字节
    // read_index指向buffer中客户数据的尾部的下一字节
    // buffer中第0~checked_index字节都已经分析完毕,checked_index~(read_index-1)字节由下面循环挨个分析
    for ( ; checked_index < read_index; ++checked_index )
    {
    	// 获取当前要分析的字节
        temp = buffer[ checked_index ];
        // 若干当前的字节是\r 即回车 说明可能读到一个完整的行
        if ( temp == '\r' )
        {
        	// 如果\r字符碰巧是目前buffer中最后一个已经读入的客户数据
        	// 那么这次分析没有读取到一个完整的行, 则还需要读取更多的客户数据用于分析
            if ( ( checked_index + 1 ) == read_index )
            {
                return LINE_OPEN;
            }
            // 如果下一个字符是\n 说明成功读取到一个完整的行
            else if ( buffer[ checked_index + 1 ] == '\n' )
            {
                buffer[ checked_index++ ] = '\0';
                buffer[ checked_index++ ] = '\0';
                return LINE_OK;
            }
            // 否则 说明客户发送的http数据存在语法问题
            return LINE_BAD;
        }
        // 如果当前字节是\n 即换行符 说明也有可能读取到一个完整的行
        else if( temp == '\n' )
        {
            if( ( checked_index > 1 ) &&  buffer[ checked_index - 1 ] == '\r' )
            {
                buffer[ checked_index-1 ] = '\0';
                buffer[ checked_index++ ] = '\0';
                return LINE_OK;
            }
            return LINE_BAD;
        }
    }
    // 如果所有内容都分析完毕 也没有遇到\r 
    // 则说明还需要更多客户数据进行分析
    return LINE_OPEN;
}

// 分析请求行
HTTP_CODE parse_requestline( char* szTemp, CHECK_STATE& checkstate )
{
    char* szURL = strpbrk( szTemp, " \t" );
    // 如果请求行中没有空白符 或 \t 则http请求必有问题
    if ( ! szURL )
    {
        return BAD_REQUEST;
    }
    *szURL++ = '\0';

    char* szMethod = szTemp;
    // 这里我们仅支持get方法
    if ( strcasecmp( szMethod, "GET" ) == 0 )
    {
        printf( "The request method is GET\n" );
    }
    else
    {
        return BAD_REQUEST;
    }

    szURL += strspn( szURL, " \t" );
    char* szVersion = strpbrk( szURL, " \t" );
    if ( ! szVersion )
    {
        return BAD_REQUEST;
    }
    *szVersion++ = '\0';
    szVersion += strspn( szVersion, " \t" );
    // 仅支持http1.1
    if ( strcasecmp( szVersion, "HTTP/1.1" ) != 0 )
    {
        return BAD_REQUEST;
    }
	// 检查URL是否合法
    if ( strncasecmp( szURL, "http://", 7 ) == 0 )
    {
        szURL += 7;
        szURL = strchr( szURL, '/' );
    }

    if ( ! szURL || szURL[ 0 ] != '/' )
    {
        return BAD_REQUEST;
    }

    //URLDecode( szURL );
    printf( "The request URL is: %s\n", szURL );
    // http请求行处理完毕,状态转移到头部字段的分析
    checkstate = CHECK_STATE_HEADER;
    return NO_REQUEST;
}
// 分析头部字段
HTTP_CODE parse_headers( char* szTemp )
{
	// 遇到一个空行 说明我们得到了一个正确的http请求
    if ( szTemp[ 0 ] == '\0' )
    {
        return GET_REQUEST;
    }
    // 处理host头部字段
    else if ( strncasecmp( szTemp, "Host:", 5 ) == 0 )
    {
        szTemp += 5;
        szTemp += strspn( szTemp, " \t" );
        printf( "the request host is: %s\n", szTemp );
    }
    // 其他字段暂不处理
    else
    {
        printf( "I can not handle this header\n" );
    }

    return NO_REQUEST;
}
// 分析http请求的入口函数
HTTP_CODE parse_content( char* buffer, int& checked_index, CHECK_STATE& checkstate, int& read_index, int& start_line )
{
    LINE_STATUS linestatus = LINE_OK;// 记录当前行的读取状态
    HTTP_CODE retcode = NO_REQUEST;// 记录http请求的处理结果
    while( ( linestatus = parse_line( buffer, checked_index, read_index ) ) == LINE_OK )
    {
    	// start_line是行在buffer中的起始位置
        char* szTemp = buffer + start_line;
        // 记录下一行的起始位置
        start_line = checked_index;
        // checkstate 记录主状态机当前状态
        switch ( checkstate )
        {
        	// 第一个状态,分析请求行
            case CHECK_STATE_REQUESTLINE:
            {
                retcode = parse_requestline( szTemp, checkstate );
                if ( retcode == BAD_REQUEST )
                {
                    return BAD_REQUEST;
                }
                break;
            }
            // 第二个状态,分析头部字段
            case CHECK_STATE_HEADER:
            {
                retcode = parse_headers( szTemp );
                if ( retcode == BAD_REQUEST )
                {
                    return BAD_REQUEST;
                }
                else if ( retcode == GET_REQUEST )
                {
                    return GET_REQUEST;
                }
                break;
            }
            default:
            {
                return INTERNAL_ERROR;
            }
        }
    }
    // 若没有读取到一个完整的行 则表示还需要继续读取客户端数据才能进一步分析
    if( linestatus == LINE_OPEN )
    {
        return NO_REQUEST;
    }
    else
    {
        return BAD_REQUEST;
    }
}

int main( int argc, char* argv[] )
{
    if( argc <= 2 )
    {
        printf( "usage: %s ip_address port_number\n", basename( argv[0] ) );
        return 1;
    }
    const char* ip = argv[1];
    int port = atoi( argv[2] );
    
    struct sockaddr_in address;
    bzero( &address, sizeof( address ) );
    address.sin_family = AF_INET;
    inet_pton( AF_INET, ip, &address.sin_addr );
    address.sin_port = htons( port );
    
    int listenfd = socket( PF_INET, SOCK_STREAM, 0 );
    assert( listenfd >= 0 );
    
    int ret = bind( listenfd, ( struct sockaddr* )&address, sizeof( address ) );
    assert( ret != -1 );
    
    ret = listen( listenfd, 5 );
    assert( ret != -1 );
    
    struct sockaddr_in client_address;
    socklen_t client_addrlength = sizeof( client_address );
    int fd = accept( listenfd, ( struct sockaddr* )&client_address, &client_addrlength );
    if( fd < 0 )
    {
        printf( "errno is: %d\n", errno );
    }
    else
    {
        char buffer[ BUFFER_SIZE ];
        memset( buffer, '\0', BUFFER_SIZE );
        int data_read = 0;
        int read_index = 0;
        int checked_index = 0;
        int start_line = 0;
        CHECK_STATE checkstate = CHECK_STATE_REQUESTLINE;
        while( 1 )
        {
            data_read = recv( fd, buffer + read_index, BUFFER_SIZE - read_index, 0 );
            if ( data_read == -1 )
            {
                printf( "reading failed\n" );
                break;
            }
            else if ( data_read == 0 )
            {
                printf( "remote client has closed the connection\n" );
                break;
            }
    
            read_index += data_read;
            HTTP_CODE result = parse_content( buffer, checked_index, checkstate, read_index, start_line );
            if( result == NO_REQUEST )
            {
                continue;
            }
            else if( result == GET_REQUEST )
            {
                send( fd, szret[0], strlen( szret[0] ), 0 );
                break;
            }
            else
            {
                send( fd, szret[1], strlen( szret[1] ), 0 );
                break;
            }
        }
        close( fd );
    }
    
    close( listenfd );
    return 0;
}

从状态机的状态转移图

初始状态:LINE_OK

8.7 提高服务器性能的其他建议

8.7.1 池

根据不同的资源类型,池可以分为多种,常见的有内存吃、进程池、线程池和连接池

8.7.2 数据复制

高性能服务器应该避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间的时候。如果内核可以直接处理从socket或者文件读入的数据,则应用程序就没必要将这些数据从内核缓冲区复制到应用程序缓冲区中。这里说的“直接处理”指的是应用程序不关心这些数据的内容,不需要对它们做任何分析。比如p服务器,当客户请求一个文件时,服务器只需要检测目标文件是否存在,以及客户是否有读取它的权限,而绝对不会关心文件的具体内容。这样的话,即服务器就无须把目标文件的内容完整地读人到应用程序缓冲区中并调用send函数来发送,而是可以使用“零拷贝”函数sendfile来直接将其发送给客户端。

此外,用户代码内部(不访问内核)的数据复制也是应该避免的。举例来说,当两个工作进程之间要传递大量的数据时,我们就应该考虑使用共享内存来在它们之间直接共享这些数据,
而不是使用管道或者消息队列来传递。又比如代码清单83所示的解析HTTP请求的实例中,
我们用指针(start line)来指出每个行在buffer中的起始位置,以便随后对行内容进行访问,而 不是把行的内容复制到另外一个缓冲区中来使用,因为这样既浪费空间,又效率低下。

8.7.3 上下文切换和锁

并发程序必须考虑上下文切换(context switch)的问题,即进程切换或线程切换导致的的系统开销。即使是I/O密集型的服务器,也不应该使用过多的工作线程(或工作进程,下同),否则线程间的切换将占用大量的CPU时间,服务器真正用于处理业务逻辑的CPU时间的比重就显得不足了。因此,为每个客户连接都创建一个工作线程的服务器模型是不可取 的。图8-11所描述的半同步/半异步模式是一种比较合理的解决方案,它允许一个线程同 时处理多个客户连接。此外,多线程服务器的一个优点是不同的线程可以同时运行在不同的CPU上。当线程的数量不大于CPU的数目时,上下文的切换就不是问题了。

并发程序需要考虑的另外一个问题是共享资源的加锁保护。锁通常被认为是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。因此,服务器如果有更好的解决方案,就应该避免使用锁。显然,图8-11所描述的半同步/半异步模式就比图8-10所描述的半同步/半反应堆模式的效率高。如果服务器必须使用 “锁”,则可以考虑减小锁的粒度,比如使用读写锁。当所有工作线程都只读取一块共享内存的内容时,读写锁并不会增加系统的额外开销。只有当其中某一个工作线程需要写这块内存时,系统才必须去锁住这块区域。

结语

文章仅作为个人学习笔记记录,记录从0到1的一个过程

希望对您有一点点帮助,如有错误欢迎小伙伴指正

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。