• IOCP编程之基本原理


    在我的博客之前写了很多关于IOCP的“行云流水”似的看了让人发狂的文章,尤其是几篇关于IOCP加线程池文章,更是让一些功力不够深厚的初学IOCP者,有种吐血的感觉。为了让大家能够立刻提升内力修为,并且迅速的掌握IOCP这个Windows平台上的乾坤大挪移心法,这次我决定给大家好好补补这个基础。

    要想彻底征服IOCP,并应用好IOCP这个模型,首先就让我们穿越到遥远的计算机青铜器时代(以出现PC为标志),那时候普通的PC安装的还是DOS平台,微软公司主要靠这个操作系统在IT界的原始丛林中打拼,在DOS中编写程序,不得不与很多的硬件直接打交道,而最常操作的硬件无非是键盘、声显卡、硬盘等等,这些设备都有一个特点就是速度慢,当然是相对于PC平台核心CPU的速度而言,尤其是硬盘这个机械电子设备,其速度对于完全电子化得CPU来说简直是“相对静止”的设备。很多时候CPU可以干完n件(n>1000)事情的时间中,这些硬件可能还没有完成一件事情,显然让CPU和这些硬件同步工作将是一种严重的浪费,并且也不太可能,此时,聪明的硬件设计师们发明了一种叫做中断的操作方式,用以匹配这种速度上的严重差异。中断工作的基本原理就是,CPU首先设置一个类似回调函数的入口地址,其次CPU对某个硬件发出一个指令,此时CPU就去干别的活计了,最后那个慢的象蜗牛一样的硬件执行完那个指令后,就通知CPU,让CPU暂时“中断”手头的工作,去调用那个“回调函数”。至此一个完整的中断调用就结束了。这个模型曾经解决了显卡与CPU不同步的问题,最重要的是解决了硬盘速度与CPU速度严重不匹配的问题,并因此还派生出了更有名的DMA(直接内存访问技术,主要是指慢速硬件可以读写原本只能由CPU直接读写的内存)硬盘IO方式。(注意这里说的中断工作方式只是中断工作方式的一种,并不是全部,详细的中断原理请参阅其它专业文献。)

    其实“中断”方式更像是一种管理模型,比如在一个公司中,如果要老板时时刻刻盯着员工作事情,那么除非是超人,否则无人能够胜任,同时对于老板这个稀缺资源来说也是一种极起严重的浪费。更多时候老板只是发指令给员工,然后员工去执行,而老板就可以做别的事情,或者干脆去打高尔夫休息,当员工完成了任务就会通过电话、短信、甚至e-mail等通知老板,此时老板就去完成一个响应过程,比如总结、奖罚、发出新指令等等。由此也看出如果一个公司的“老板占用率”(类似CPU占用率)太高,那么就说明两种情况:要么是它的员工很高效,单位时间内完成的指令非常多;要么是公司还没有建立有效的“中断”响应模型。如果你的公司是后者,那么你就可以试着用这个模型改造公司的管理了,由此你可以晋升到管理层,而不用再去管你的服务端程序有没有使用IOCP了,呵呵呵。

    如果真的搞明白了这个传说中的“中断”操作方式,那么理解IOCP的基本原理就不费劲了。

    结束了计算机的青铜时代后,让我们穿越到现在这个“计算机蒸汽”时代,(注意不是“计算机IT”时代,因为计算机还没法自己编写程序让自己去解决问题)。在现代,Windows几乎成了PC平台上的标准系统,而PC平台上的几大件还是没有太大的变化,除了速度越来越快。而因为操作系统的美妙封装,我们也不用再去直接同硬件打交道了,当然编写驱动程序的除外。

    在Windows平台上,我们不断的调用着WriteFile和ReadFile这些抽象的函数,操作着“文件”这种抽象的信息集合,很多时候调用这些函数时,是以一种“准同步”的方式操作硬件的,比如要向一个文件中写入1M的信息,只有等到WriteFile函数返回,操作才算结束,这个过程中,我们的程序则类似死机一样,等待硬盘写入操作的结束(实际是被系统切换出了当前的CPU时间片)。于此同时,调用了WriteFile的线程则无法干别的任何事情。因为整个线程是在以一种称为过程化的模型中运行,所有的处理流程全部是线性的。对于程序的流畅编写来说,线性化的东西是一个非常好的东西,甚至几乎早期很多标准的算法都是基于程序是过程化得这一假设而设计的。而对于一些多任务、多线程环境来说,这种线性的工作方式会使系统严重低效,甚至造成严重的浪费,尤其在现代多核CPU已成为主流的时候,显然让一个CPU内核去等待另一个CPU内核完成某事后再去工作,是非常愚蠢的一种做法。

    面对这种情况,很多程序员的选择是多线程,也就是专门让一个线程去进行读写操作,而别的线程继续工作,以绕开这些看起来像死机一样的函数,但是这个读写线程本身还是以一种与硬盘同步的方式工作的。然而这并不是解决问题的最终方法。我们可以想象一个繁忙的数据库系统,要不断的读写硬盘上的文件,可能在短短的一秒钟时间就要调用n多次WriteFile或ReadFile,假设这是一个网站的后台数据库,那么这样的读写操作有时还可能都是较大的数据块,比如网站的图片就是比较典型的大块型数据,这时显然一个读写线程也是忙不过来的,因为很有可能一个写操作还没有结束,就会又有读写操作请求进入,这时读写线程几乎变成了无响应的一个线程,可以想象这种情况下,程序可能几乎总在瘫痪状态,所有其它的线程都要等待读写操作线程完活。也许你会想多建n个线程来进行读写操作,其实这种情况会更糟糕,因为不管你有多少线程,先不说浪费了多少系统资源,而你读写的可能是相同的一块硬盘,只有一条通道,结果依然是一样的,想象硬盘是独木桥,而有很多人(线程)等着过桥的情形,你就知道这更是一个糟糕的情形。所以说在慢速的IO面前,多线程往往不是“万灵丹”。

    面对这种情形,微软公司为Windows系统专门建立了一种类似“青铜时代”的中断方式的模型来解决这个问题。当然,不能再像那个年代那样直接操作硬件了,需要的是旧瓶装新酒了。微软是如何做到的呢,实际还是通过“回调函数”来解决这个问题的,大致也就是要我们去实现一个类似回调函数的过程,主要用于处理来自系统的一些输入输出操作“完成”的通知,相当于一个“中断”,然后就可以在过程中做输入输出完成的一些操作了。比如在IO操作完成后删除缓冲,继续发出下一个命令,或者关闭文件,设备等。实际上从逻辑的角度来讲,我们依然可以按照线性的方法来分析整个过程,只不过这是需要考虑的是两个不同的函数过程之间的线性关系,第一个函数是发出IO操作的调用者,而第二个函数则是在完成IO操作之后的被调用者,。而被调用的这个函数在输入输出过程中是不活动的,也不占用线程资源,它只是个过程(其实就是个函数,内存中的一段代码而已)。调用这些函数则需要一个线程的上下文,实际也就是一个函数调用栈,很多时候,系统会借用你进程空间中线程来调用这个过程,当然前提条件是事先将可以被利用的线程设置成“可警告”状态,这也是线程可警告状态的全部意义,也就是大多数内核同步等待函数bAlertable(有些书翻译做可警告的,我认为应该理解为对IO操作是一种“时刻警惕”的状态)参数被传递TRUE值之后的效果。比如:WaitForSingleObjectEx、SleepEx等等。

    当然上面说的这种方式其实是一种“借用线程”的方式,当进程中没有线程可借,或者可借的线程本身也比较忙碌的时候,会造成严重的线程争用情况,从而造成整体性能低下,这个方式的局限性也就显现出来了。注意“可警告”状态的线程,并不总是在可以被借用的状态,它们本身往往也需要完成一些工作,而它调用一些能够让它进入等待状态的函数时,才可以被系统借用,否则还是不能被借用的。当然借用线程时因为系统有效的保护了栈环境和寄存器环境,所以被借用的线程再被还回时线程环境是不会被破坏的。

    鉴于借用的线程的不方便和不专业,我们更希望通过明确的“创建”一批专门的线程来调用这些回调函数(为了能够更深入的理解,可以将借用的线程想象成出租车,而将专门的线程想象成私家车),因此微软就发明了IOCP“完成端口”这种线程池模型,注意IOCP本质是一种线程池的模型,当然这个线程池的核心工作就是去调用IO操作完成时的回调函数,这就叫专业!这也是IOCP名字的来由,这就比借用线程的方式要更加高效和专业,因为这些线程是专门创建来做此工作的,所以不用担心它们还会去做别的工作,而造成忙碌或不响应回调函数的情况,另外因为IO操作毕竟是慢速的操作,所以几个线程就已经足可以应付成千上万的输入输出完成操作的请求了(还有一个前提就是你的回调函数做的工作要足够少),所以这个模型的性能是非常高的。也是现在Windows平台上性能最好的输入输出模型。它首先就被用来处理硬盘操作的输入输出,同时它也支持邮槽、管道、甚至WinSock的网络输入输出。

    至此对于完成端口的本质原理应该有了一个比较好的理解,尤其是掌握了IOCP是线程池模型的这一本质,那么对于之后的IOCP实际应用就不会有太多的疑问了。接下去就让我们从实际编程的角度来了解一下IOCP,也为彻底掌握IOCP编程打下坚实的基础。

    要应用IOCP,首先就要我们创建一个叫做IOCP的内核对象,这需要通过CreateIoCompletionPort这个函数来创建,这个函数的原型如下:

    HANDLE WINAPI CreateIoCompletionPort(

      __in          HANDLE FileHandle,

      __in          HANDLE ExistingCompletionPort,

      __in          ULONG_PTR CompletionKey,

      __in          DWORD NumberOfConcurrentThreads

    );

    这个函数是个本身具有多重功能的函数(Windows平台上这样的函数并不多),需要用不同的方式来调用,以实现不同的功能,它的第一个功能正如其名字所描述的“Create”,就是创建一个完成端口的内核对象,要让他完成这个功能,只需要指定NumberOfConcurrentThreads参数即可,前三个参数在这种情况下是没有意义的,只需要全部传递NULL即可,象下面这样我们就创建了一个完成端口的内核对象:

    HANDLE hICP = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,0,1);

    这里首先解释下为什么第一个参数不是NULL而是INVALID_HANDLE_VALUE,因为第一个参数按照定义是一个文件的句柄,也就是需要IOCP操作的文件句柄,而代表“NULL”文件句柄的实际值是INVALID_HANDLE_VALUE,这是因为NULL实际等于0,而0这个文件句柄被用于特殊用途,所以要用INVALID_HANDLE_VALUE来代表“NULL”意义的文件,INVALID_HANDLE_VALUE的值是-1或者0xFFFFFFFF。

    最后一个参数NumberOfConcurrentThreads就有必要好好细细的说说了,因为很多文章中对于这个参数总是说的含糊其辞,不知所云,有些文章中甚至人云亦云的说赋值为CPU个数的2倍即可,所谓知其然,不知其所以然。其实这个参数的真实含义就是“真正并发同时执行的最大线程数”,这个并发是真并发,怎么去理解呢,如果你有两颗CPU,而你赋值为2那么就是说,在每颗CPU上执行一个线程,并且真正的并发同时执行,当然如果你设置了比CPU数量更大的数值,它的含义就变成了一个理论并发值,而实际系统的最大可能的严格意义上的并发线程数就是CPU个数,也就是你在任务管理器中看到的CPU个数(可能是物理个数,也可能是内核个数,还有可能是超线程个数,或者它们的积)。讲到这里大家也许就有疑问了,为什么有些文章资料中说要设置成CPU个数的2倍呢?这通常是一个半经验值,因为大多数IOCP完成回调的过程中,需要一些逻辑处理,有些是业务性的,有些要访问数据库,有些还可能访问硬盘,有些可能需要进行数据显示等等,无论哪种处理,这总是要花费时间的,而系统发现你设置了超过CPU个数的并发值时,那么它就尽可能的来回切换这些线程,使他们在一个时间段内看起来像是并发的,比如在1ms的时间周期内,同时有4个IOCP线程被调用,那么从1ms这段时间来看的话,可以认为是有4个线程被并发执行了,当然时间可以无限被细分,真并发和模拟并发实际就是针对时间细分的粒度来说的。这样一来如何设置并发数就是个设计决策问题,决策的依据就是你的回调函数究竟要干些什么活,如果是时间较长的活计,就要考虑切换其它线程池来完成,如果是等待性质的活计,比如访问硬盘,等待某个事件等,就可以设置高一点的并发值,强制系统切换线程造成“伪并发”,如果是非常快速的活计,那么就直接设置CPU个数的并发数就行了,这时候防止线程频繁切换是首要任务。当然并发数最好是跟踪调试一下后再做决定,默认的推荐值就是CPU个数的2倍了。(绕了一大圈我还是“人云亦云”了一下,哎呦!谁扔的砖头?!)

    上面的全部就是创建一个完成端口对象,接下来就是打造线程了,打造的方法地球人都知道了,就是CreateThread,当然按照人云亦云的说法应该替之以_beginthread或_beginthreadex,原因嘛?你想知道?真的想知道?好了看你这么诚恳的看到了这里,那就告诉你吧,原因其实就是因为我们使用的语言从本质上说是C/C++,很多时候我们需要在线程函数中调用很多的C/C++味很重的库函数,而有些函数是在Windows诞生以前甚至是多线程多任务诞生以前就诞生了,这些老爷级的函数很多都没有考虑过多线程安全性,还有就是C++的全局对象静态对象等都需要调用它们的构造函数来初始化,而调用的主体就是线程,基于这些原因就要使用C/C++封装过的创建线程函数来创建线程,而CreateThread始终是Windows系统的API而已,它是不会考虑每种语言环境的特殊细节的,它只考虑系统的环境。

    好了让我们继续打造线程的话题,要创建线程,实际核心就是准备一个线程函数,原型如下:

    1、使用CreateThread时:

    DWORD WINAPI ThreadProc(LPVOID lpParameter);

    2、使用_beginthread时:

    void __cdecl ThreadProc( void * pParameter );

    3、使用_beginthreadex时:

    unsigned int __stdcall ThreadProc(void* pParam);

    其实上面三个函数原型都是很简单的,定义一个线程函数并不是什么难事,而真正困难的是对线程的理解和定义一个好的线程函数。这里我就不在多去谈论关于线程原理和如何写好一个线程函数的内容了,大家可以去参阅相关的文献。

    现在我们接着讨论IOCP的专用线程如何编写,IOCP专用线程编写的核心工作就是调用一个同步函数GetQueuedCompletionStatus,为了理解的方便性,你可以想象这个函数的工作原理与那个有名的GetMessage是类似的,虽然这种比喻可能不太确切,但是他们工作方式是有些类似的地方,它们都会使调用它们的线程进入一种等待状态,只是这个函数不是等待消息队列中的消息,它是用来等待“被排队的完成状态”(就是它名字的含义)的,排队的完成状态,其实就是IO操作完成的通知(别告诉我你还不知道什么是IO操作),如果当前没有IO完成的通知,那么这个函数就会让线程进入“等待状态”,实际也就是一种“可警告”的状态,这样系统线程调度模块就会登记这个线程,一旦有IO完成通知,系统就会“激活”这个线程,立即分配时间片,让该线程开始继续执行,已完成IO完成通知的相关操作。

    首先让我看看GetQueuedCompletionStatus的函数原型:

    BOOL WINAPI GetQueuedCompletionStatus(

      __in          HANDLE CompletionPort,

      __out         LPDWORD lpNumberOfBytes,

      __out         PULONG_PTR lpCompletionKey,

      __out         LPOVERLAPPED* lpOverlapped,

      __in          DWORD dwMilliseconds

    );

    第一个参数就是我们之前创建的那个完成端口内核对象的句柄,这个参数实际也就是告诉系统,我们当前的线程是归哪个完成端口对象来调度。

    第二个参数是一个比较有用的参数,在函数返回后它将告诉我们这一次的IO操作实际传输或者接收了多少个字节的信息,这对于我们校验数据收发完整性非常有用。

    第三个参数是与完成端口句柄绑定的一个一对一的数据指针,当然这个数据是我们绑到这个完成端口句柄上的,其实这个参数也是类似本人博客文章中所提到的那个“火车头”的作用的,它的作用和意义就是在我们得到完成通知时,可以拿到我们在最开初创建完成端口对象时绑定到句柄上的一个自定义的数据。这里给一个提示就是,在用C++的类封装中,通常这个参数我们会在绑定时传递类的this指针,而在GetQueuedCompletionStatus返回时又可以拿到这个类的this指针,从而可以在这个完成线程中调用类的方法。

    第四个参数就是在本人其它IOCP相关博文中详细介绍过的重叠操作的数据结构,它也是一个火车头,这里就不在赘述它的用法了,请大家查阅本人其它博文拙作。

    第五个参数是一个等待的毫秒数,也就是GetQueuedCompletionStatus函数等待IO完成通知的一个最大时间长度,如果超过这个时间值,GetQueuedCompletionStatus就会返回,并且返回值一个0值,此时调用GetLastError函数会得到一个明确的WAIT_TIMEOUT,也就是说它等待超时了,也没有等到一个IO完成通知。这时我们可以做一些相应的处理,而最常见的就是再次调用GetQueuedCompletionStatus函数让线程进入IO完成通知的等待状态。当然我们可以传递一个INFINITE值,表示让此函数一直等待,直到有一个完成通知进入完成状态队列。当然也可以为这个参数传递0值,表示该函数不必等待,直接返回,此时他的工作方式有些类似PeekMessage函数。

    函数的参数和原型都搞清楚了,下面就让我们来看看调用的例子:

    UINT CALLBACK IOCPThread(void* pParam)

    {

    CoInitialize(NULL);

           DWORD dwBytesTrans = 0;

           DWORD dwPerData = 0;

    LPOVERLAPPED lpOverlapped = NULL;

           while(1)

           {

                  BOOL bRet = GetQueuedCompletionStatus( hICP,&dwBytesTrans

    ,&dwPerData,&lpOverlapped,INFINITE);

                  if( NULL == lpOverlapped )

                  {

                         DWORD dwError = GetLastError();

                         ......//错误处理

    }

                  PMYOVERLAPPED pMyOL

    = CONTAINING_RECORD(lpOverlapped, MYOVERLAPPED, m_ol);

                  if( !HasOverlappedIoCompleted(lpOverlapped) )

                  {//检测到不是一个真正完成的状态

                         DWORD dwError = GetLastError();

                         ......//错误处理

                  }

                         ...... //继续处理

    }

           return 0;

    }

    在这个线程函数中,我们写了一个死循环,这个是必要的,因为这个线程要反复处理IO完成通知的操作。跟我们常见的消息循环是异曲同工。

    有了线程函数,接着就是创建线程了,对于IOCP来说,创建多少线程其实是一个决策问题,一般的原则就是创建的实际线程数量,不应小于调用CreateIoCompletionPort创建完成端口对象时指定的那个最大并发线程数。一般的指导原则是:如果完成线程的任务比较繁重大多数情况下执行的是其它的慢速等待性质的操作(比如磁盘磁带读写操作,数据库查询操作,屏幕显示等)时,由于这些操作的特点,我们可以适当的提高初始创建的线程数量。但是如果是执行计算密集型的操作时(比如网游服务端的场景变换运算,科学计算,工程运算等等),就不易再靠增加线程数来提高性能,因为这类运算会比较耗费CPU,没法切换出当前CPU时间片,多余的线程反倒会造成因为频繁的线程切换而造成整个程序响应性能的下降,此时为了保证IOCP的响应性,可以考虑再建立线程池来接力数据专门进行计算,这也是我的博文《IOCP编程之“双节棍”》篇中介绍的用线程池接力进行计算并提高性能的思想的核心。

    下面的例子展示了如何创建IOCP线程池中的线程:

    SYSTEM_INFO si = {};

    GetSystemInfo(&si);

    //创建CPU个数个IOCP线程

    for( int i = 0; i < si.dwNumberOfProcessors; i ++ )

    {

    UINT nThreadID = 0;

    //以暂停的状态创建线程状态

    HANDLE hThread = (HANDLE)_beginthreadex(NULL,0,IOCPThread

    ,(void*)pThreadData,CREATE_SUSPENDED,(UINT*)&nThreadID);

    //然后判断创建是否成功

    if( NULL == reinterpret_cast<UINT>(m_hThread)

                         || 0xFFFFFFFF == reinterpret_cast<UINT>(m_hThread) )

    {//创建线程失败

                  ......//错误处理

    }

    ::ResumeThread(hThread);//启动线程

    }

    创建好了IOCP的线程池,就可以往IOCP线程池中添加用来等待完成的那些重叠IO操作的句柄了,比如:重叠IO方式的文件句柄,重叠IO操作方式的SOCKET句柄,重叠IO操作的命名(匿名)管道等等。上面的这个操作可以被称作将句柄绑定到IOCP,绑定的方法就是再次调用CreateIoCompletionPort函数,这次调用时,就需要明确的指定前两个参数了,例子如下:

    //创建一个重叠IO方式的SOCKET

    SOCKET skSocket = ::WSASocket(AF_INET,SOCK_STREAM,IPPROTO_IP,

                                NULL,0,WSA_FLAG_OVERLAPPED);

    ......//其它操作

    //绑定到IOCP

    CreateIoCompletionPort((HANDLE)skSocket,hICP,NULL,0);

    由代码就可以看出这步操作就非常的简单了,直接再次调用CreateIoCompletionPort函数即可,只是这次调用的意义就不是创建一个完成端口对象了,而是将一个重叠IO方式的对象句柄绑定到已创建好的完成端口对象上。

    至此整个IOCP的基础知识算是介绍完了,作为总结,可以回顾下几个关键步骤:

    1、  用CreateIoCompletionPort创建完成端口;

    2、  定义IOCP线程池函数,类似消息循环那样写一个“死循环”调用GetQueuedCompletionStatus函数,并编写处理代码;

    3、  创建线程;

    4、  将重叠IO方式的对象句柄绑定到IOCP上。

    只要记住了上面4个关键步骤,那么使用IOCP就基本掌握了。最后作为补充,让我再来讨论下这个核心步骤之外的一些附带的步骤。

    现在假设我们已经创建了一个这样的IOCP线程池,而且这个线程池也工作的非常好了,那么我们该如何与这个线程池中的线程进行交互呢?还有就是我们如何让这个线程池停下来?

    其实这个问题可以很简单的来思考,既然IOCP线程池核心的线程函数中有一个类似消息循环的结构,那么是不是也有一个类似PostMessage之类的函数来向其发送消息,从而实现与IOCP线程的交互呢?答案是肯定的,这个函数就是PostQueuedCompletionStatus,现在看到它的名字,你应该已经猜到它的用途了吧?对了,它就是用来向这个类似消息循环的循环中发送自定义的“消息”的,当然,它不是真正的消息,而是一个模拟的“完成状态”。这个函数的原型如下:

    BOOL WINAPI PostQueuedCompletionStatus(

      __in          HANDLE CompletionPort,

      __in          DWORD dwNumberOfBytesTransferred,

      __in          ULONG_PTR dwCompletionKey,

      __in          LPOVERLAPPED lpOverlapped

    );

    它的参数与GetQueuedCompletionStatus类似,其实为了理解上的简单,我们可以认为PostQueuedCompletionStatus的参数就是原样的被copy到了GetQueuedCompletionStatus,怎么调用这个函数就应该可以理解了。通常在需要停止整个IOCP线程池工作时,就可以调用这个函数发送一个特殊的标志,比如设定dwCompletionKey为NULL,并且在自定义lpOverlapped指针结构之后带上一个表示关闭的标志等。这样在线程函数中就可以通过判定这些条件而明确的知道当前线程池需要关闭。当然也可以定义其它的操作扩展码来指定IOCP线程池执行指定的操作。下面的例子代码演示了如何发送一个IO完成状态:

    MYOVERLAPPED *pOL = new MYOVERLAPPED ;

    .......//其它初始化代码

    pOL->m_iOpCode = OP_CLOSE;//指定关闭操作码

    .......

    PostQueuedCompletionStatus(hICP,0,NULL,(LPOVERLAPPED)pOL);

    至此IOCP的基础性的支持算是介绍完了,本篇文章的主要目的是为了让大家理解IOCP的本质和工作原理,为轻松驾驭IOCP这个编程模型打下坚实的基础。最终需要掌握的就是认识到IOCP其实就是一个管理IO操作的自定义线程池这一本质。实际编码时决策性的问题就是理解最大并发数和预创建线程数的意义,并根据实际情况设定一个合理的值。

  • 相关阅读:
    面试题目——《CC150》链表
    TCP/IP——链路层
    TCP/IP——基本知识
    面试题目——《CC150》数组与字符串
    Java递归算法——三角数字(消除递归)
    Java排序算法——拓扑排序
    Java排序算法——希尔排序
    Python学习笔记——条件和循环
    Python学习笔记——集合类型
    英文写作——冠词的使用(Use 0f Articles)
  • 原文地址:https://www.cnblogs.com/weekbo/p/9884329.html
Copyright © 2020-2023  润新知