zoukankan      html  css  js  c++  java
  • 线程

    编译:

    -lpthread

    Linux系统下的多线程遵循POSIX线程接口,称为pthread,通过系统调用clone()来实现。使用头文件pthread.h。 

     创建

    int pthread_create(pthread_t *tidp,const pthread_attr_t *attr,(void*)(*start_rtn)(void*),void *arg);
    pthread_create(&p, NULL, (void*)fun, NULL); 

    第一个参数为指向线程标识符的指针。

    第二个参数用来设置线程属性。
    第三个参数是线程运行函数的起始地址。
    最后一个参数是运行函数的参数

     等待

     pthread_join()以阻塞的方式等待thread指定的线程结束

    int pthread_join(pthread_t thread, void **retval);

     thread: 线程标识符,即线程ID。       retval: 用户定义的指针,用来存储被等待线程的返回值。

    pthread_join(p, NULL);

    退出 

    pthread_exit      终止调用它的线程并返回一个指向某个对象的指针。
    void pthread_exit(void* retval);
    pthread_exit ("thread all done"); // 重点看 pthread_exit() 的参数,是一个字串,这个参数的指针可以通过 pthread_join( thread1, &pth_join_ret1);得到


    pthread_create()之前的线程属性设置
    默认的属性为NULL  非绑定、非分离、缺省的堆栈、与父进程同样级别的优先级。
    初始化的函数为pthread_attr_init属性对象主要包括是否绑定、是否分离、堆栈地址、堆栈大小、优先级。

    绑定 

     轻进程(LWP:Light Weight Process)。轻进程可以理解为内核线程,它位于用户层和系统层之间。系统对线程资源的分配、对线程的控制是通过轻进程来实现的,一个轻进程可以控制一个或多个线程。默认状况下,启动多少轻进程、哪些轻进程来控制哪些线程是由系统来控制的,这种状况即称为非绑定的。绑定状况下,则顾名思义,即某个线程固定的"绑"在一个轻进程之上。被绑定的线程具有较高的响应速度,这是因为CPU时间片的调度是面向轻进程的,绑定的线程可以保证在需要的时候它总有一个轻进程可用。通过设置被绑定的轻进程的优先级和调度级可以使得绑定的线程满足诸如实时反应之类的要求。

     设置线程绑定状态的函数为 pthread_attr_setscope,它有两个参数,第一个是指向属性结构的指针,第二个是绑定类型,它有两个取值PTHREAD_SCOPE_SYSTEM(绑定的)和PTHREAD_SCOPE_PROCESS(非绑定的)。

     #include <pthread.h>

    pthread_attr_t attr;
    pthread_t tid;
    /*初始化属性值,均设为默认值*/
    pthread_attr_init(&attr); 
    pthread_attr_setscope(&attr, PTHREAD_SCOPE_SYSTEM);   //绑定
    pthread_create(&tid, &attr, (void *) my_function, NULL);

     线程分离状态

     线程的分离状态决定一个线程以什么样的方式来终止自己。非分离的线程终止时,其线程ID和退出状态将保留,直到另外一个线程调用pthread_join.分离的线程在当它终止时,所有的资源将释放,我们不能等待它终止。

     设置线程分离状态的函数为 pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate)。第二个参数可选为PTHREAD_CREATE_DETACHED(分离线程)和 PTHREAD _CREATE_JOINABLE(非分离线程)。这里要注意的一点是,如果设置一个线程为分离线程,而这个线程运行又非常快,它很可能在 pthread_create函数返回之前就终止了,它终止以后就可能将线程号和系统资源移交给其他的线程使用,这样调用pthread_create的线程就得到了错误的线程号。要避免这种情况可以采取一定的同步措施,最简单的方法之一是可以在被创建的线程里调用 pthread_cond_timewait函数,让这个线程等待一会儿,留出足够的时间让函数pthread_create返回。设置一段等待时间,是在多线程编程里常用的方法。

    优先级 

     它存放在结构sched_param中。用函数pthread_attr_getschedparam进行提取和存放,一般说来,我们总是先取优先级,对取得的值修改后再存放回去。

     #include <pthread.h>

    #include <sched.h>
    pthread_attr_t attr; pthread_t tid;
    sched_param param;
    int newprio=20
    /*初始化属性*/
    pthread_attr_init(&attr); 
    /*设置优先级*/
    pthread_attr_getschedparam(&attr, &param); // 提取
    param.sched_priority=newprio;              // 修改
    pthread_attr_setschedparam(&attr, &param); // 放回
    pthread_create(&tid, &attr, (void *)myfunction, myarg);

    二.线程数据处理
    线程的最大优点之一是数据的共享性,各个进程共享父进程处沿袭的数据段。我们必须当心有多个不同的进程访问相同的变量。许多函数是不可重入的,即同时不能运行一个函数的多个拷贝(除非使用不同的数据段)。在函数中声明的静态变量常常带来问题,函数的返回值也会有问题。因为如果返回的是函数内部静态声明的空间的地址,则在一个线程调用该函数得到地址后使用该地址指向的数据时,别的线程可能调用此函数并修改了这一段数据。在进程中共享的变量必须用关键字volatile来定义,这是为了防止编译器在优化时(如gcc中使用-OX参数)改变它们的使用方式。为了保护变量,我们必须使用信号量、互斥等方法来保证我们对变量的正确使用。
    1.线程数据
    在单线程的程序里,有两种基本的数据:全局变量和局部变量。
    在多线程程序里,还有第三种数据类型:线程数据(TSD: Thread-Specific Data)。

    可被多个函数调用。   每个函数调用给它赋予各自的值,函数之间互不影响

     和线程数据相关的函数主要有4个:创建一个键;为一个键指定线程数据;从一个键读取线程数据;删除键

    int pthread_key_create(pthread_key_t *key, void (*destructor)(void*))

     该函数有两个参数,第一个参数就是上面声明的 pthread_key_t 变量,第二个参数是一个清理函数,用来在线程释放该线程存储的时候被调用。该函数指针可以设成 NULL ,这样系统将调用默认的清理函数。这个函数常和函数pthread_once 一起使用,为了让这个键只被创建一次。

    函数pthread_once声明一个初始化函数,第一次调用pthread_once时它执行这个函数,以后的调用将被它忽略。

    pthread_once ((pthread_once_t*once_control, void (*initroutine) (void)))
    功能:本函数使用初值为PTHREAD_ONCE_INIT的once_control变量保证init_routine()函数在本进程执行序列中仅执行一次。
    在多线程编程环境下,尽管pthread_once()调用会出现在多个线程中,init_routine()函数仅执行一次,究竟在哪个线程中执行是不定的,是由内核调度来决定。
    Linux Threads使用互斥锁和条件变量保证由pthread_once()指定的函数执行且仅执行一次,而once_control表示是否执行过。
    如果once_control的初值不是PTHREAD_ONCE_INIT(Linux Threads定义为0),pthread_once() 的行为就会不正常。
    在LinuxThreads中,实际"一次性函数"的执行状态有三种:NEVER(0)、IN_PROGRESS(1)、DONE (2),如果once初值设为1,则由于所有pthread_once()都必须等待其中一个激发"已执行一次"信号,因此所有pthread_once ()都会陷入永久的等待中;如果设为2,则表示该函数已执行过一次,从而所有pthread_once()都会立即返回0。
    int pthread_key_delete(pthread_key_t *key);
     该函数用于删除一个由pthread_key_create 函数调用创建的键。调用成功返回值为0,否则返回错误代码。 
     当线程中需要存储特殊值的时候,可以调用 pthread_setspcific() 。该函数有两个参数,第一个为前面声明的 pthread_key_t 变量,第二个为 void* 变量,这样你可以存储任何类型的值。
    如果需要取出所存储的值,调用 pthread_getspecific() 。该函数的参数为前面提到的 pthread_key_t 变量,该函数返回 void * 类型的值。
    int pthread_setspecific(pthread_key_t key, const void *value);
    void *pthread_getspecific(pthread_key_t key);
    int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));

    要注意的是,用pthread_setspecific为一个键指定新的线程数据时,必须自己释放原有的线程数据以回收空间。这个过程函数pthread_key_delete用来删除一个键,这个键占用的内存将被释放,但同样要注意的是,它只释放键占用的内存,并不释放该键关联的线程数据所占用的内存资源,而且它也不会触发函数pthread_key_create中定义的destructor函数。线程数据的释放必须在释放键之前完成。 

    我们创建一个键,并将它和某个数据相关联。我们要定义一个函数 createWindow,这个函数定义一个图形窗口(数据类型为Fl_Window *,这是图形界面开发工具FLTK中的数据类型)。由于各个线程都会调用这个函数,所以我们使用线程数据

     /* 声明一个键*/

    pthread_key_t myWinKey;
    /* 函数 createWindow */
    void createWindow ( void ) {
    Fl_Window * win;
    static pthread_once_t once= PTHREAD_ONCE_INIT;
    /* 调用函数createMyKey,创建键*/
    pthread_once ( & once, createMyKey) ;
    /*win指向一个新建立的窗口*/
    win=new Fl_Window( 00100100"MyWindow");
    /* 对此窗口作一些可能的设置工作,如大小、位置、名称等*/
    setWindow(win);
    /* 将窗口指针值绑定在键myWinKey上*/
    pthread_setpecific ( myWinKey, win);
    }
    /* 函数 createMyKey,创建一个键,并指定了destructor */
    void createMyKey ( void ) {
    pthread_keycreate(&myWinKey, freeWinKey);
    }
    /* 函数 freeWinKey,释放空间*/
    void freeWinKey ( Fl_Window * win){
    delete win;
    }

    线程取消(pthread_cancel)

    基本概念
    pthread_cancel调用并不等于线程终止,它只提出请求。线程在取消请求(pthread_cancel)发出后会继续运行,
    直到到达某个取消点(CancellationPoint)。取消点是线程检查是否被取消并按照请求进行动作的一个位置.


    与线程取消相关的pthread函数
    int pthread_cancel(pthread_t thread)
    发送终止信号给thread线程,如果成功则返回0,否则为非0值。发送成功并不意味着thread会终止。

    int pthread_setcancelstate(int state,   int *oldstate)   
    设置本线程对Cancel信号的反应,state有两种值:PTHREAD_CANCEL_ENABLE(缺省)和PTHREAD_CANCEL_DISABLE
    分别表示收到信号后设为CANCLED状态和忽略CANCEL信号继续运行;old_state如果不为NULL则存入原来的Cancel状态以便恢复。  

    int pthread_setcanceltype(int type, int *oldtype)   
    设置本线程取消动作的执行时机,type由两种取值:PTHREAD_CANCEL_DEFFEREDPTHREAD_CANCEL_ASYCHRONOUS,仅当Cancel状态为Enable时有效,分别表示收到信号后继续运行至下一个取消点再退出和立即执行取消动作(退出);oldtype如果不为NULL则存入运来的取消动作类型值。  

    void pthread_testcancel(void)
    是说pthread_testcancel在不包含取消点,但是又需要取消点的地方创建一个取消点,以便在一个没有包含取消点的执行代码线程中响应取消请求.
    线程取消功能处于启用状态且取消状态设置为延迟状态时,pthread_testcancel()函数有效。
    如果在取消功能处处于禁用状态下调用pthread_testcancel(),则该函数不起作用。
    请务必仅在线程取消线程操作安全的序列中插入pthread_testcancel()。除通过pthread_testcancel()调用以编程方式建立的取消点意外,pthread标准还指定了几个取消点。测试退出点,就是测试cancel信号.


    取消点:
    线程取消的方法是向目标线程发Cancel信号,但如何处理Cancel信号则由目标线程自己决定,或者忽略、或者立即终止、或者继续运行至Cancelation-point(取消点),由不同的Cancelation状态决定

    线程接收到CANCEL信号的缺省处理(即pthread_create()创建线程的缺省状态)是继续运行至取消点,也就是说设置一个CANCELED状态,线程继续运行,只有运行至Cancelation-point的时候才会退出。

    pthreads标准指定了几个取消点,其中包括:
    (1)通过pthread_testcancel调用以编程方式建立线程取消点。 
    (2)线程等待pthread_cond_wait或pthread_cond_timewait()中的特定条件。 
    (3)被sigwait(2)阻塞的函数 
    (4)一些标准的库调用。通常,这些调用包括线程可基于阻塞的函数。 
      
    缺省情况下,将启用取消功能。有时,您可能希望应用程序禁用取消功能。如果禁用取消功能,则会导致延迟所有的取消请求,
    直到再次启用取消请求。  
    根据POSIX标准,pthread_join()、pthread_testcancel()、pthread_cond_wait()、pthread_cond_timedwait()、sem_wait()、sigwait()等函数以及
    read()、write()等会引起阻塞的系统调用都是Cancelation-point,而其他pthread函数都不会引起Cancelation动作。
    但是pthread_cancel的手册页声称,由于LinuxThread库与C库结合得不好,因而目前C库函数都不是Cancelation-point;但CANCEL信号会使线程从阻塞的系统调用中退出,并置EINTR错误码,因此可以在需要作为Cancelation-point的系统调用前后调用pthread_testcancel(),从而达到POSIX标准所要求的目标.
    即如下代码段:
    pthread_testcancel();
    retcode = read(fd, buffer, length);
    pthread_testcancel();

    注意:
    程序设计方面的考虑,如果线程处于无限循环中,且循环体内没有执行至取消点的必然路径,则线程无法由外部其他线程的取消请求而终止。因此在这样的循环体的必经路径上应该加入pthread_testcancel()调用.


    取消类型(Cancellation Type)

    我们会发现,通常的说法:某某函数是 Cancellation Points,这种方法是容易令人混淆的。
    因为函数的执行是一个时间过程,而不是一个时间点。其实真正的 Cancellation Points 只是在这些函数中 Cancellation Type 被修改为 PHREAD_CANCEL_ASYNCHRONOUS 和修改回 PTHREAD_CANCEL_DEFERRED 中间的一段时间。

    POSIX的取消类型有两种,一种是延迟取消(PTHREAD_CANCEL_DEFERRED),这是系统默认的取消类型,即在线程到达取消点之前,不会出现真正的取消;另外一种是异步取消(PHREAD_CANCEL_ASYNCHRONOUS),使用异步取消时,线程可以在任意时间取消。


    线程终止的清理工作

    Posix的线程终止有两种情况:正常终止和非正常终止。
    线程主动调用pthread_exit()或者从线程函数中return都将使线程正常退出,这是可预见的退出方式;
    非正常终止是线程在其他线程的干预下,或者由于自身运行出错(比如访问非法地址)而退出,这种退出方式是不可预见的。

    不论是可预见的线程终止还是异常终止,都会存在资源释放的问题,在不考虑因运行出错而退出的前提下,如何保证线程终止时能顺利的释放掉自己所占用的资源,特别是锁资源,就是一个必须考虑解决的问题。
    最经常出现的情形是资源独占锁的使用:线程为了访问临界资源而为其加上锁,但在访问过程中被外界取消,如果线程处于响应取消状态,且采用异步方式响应,或者在打开独占锁以前的运行路径上存在取消点,则该临界资源将永远处于锁定状态得不到释放。外界取消操作是不可预见的,因此的确需要一个机制来简化用于资源释放的编程。

    在POSIX线程API中提供了一个pthread_cleanup_push()/ pthread_cleanup_pop()函数,
    对用于自动释放资源—从pthread_cleanup_push()的调用点到pthread_cleanup_pop()之间的程序段中的终止动作(包括调用pthread_exit()和取消点终止)都将执行pthread_cleanup_push()所指定的清理函数。

    API定义如下:
    void pthread_cleanup_push(void (*routine) (void *), void *arg)
    void pthread_cleanup_pop(int execute)

    pthread_cleanup_push()/pthread_cleanup_pop()采用先入后出的栈结构管理,void routine(void *arg)函数
    在调用pthread_cleanup_push()时压入清理函数栈,多次对pthread_cleanup_push() 的调用将在清理函数栈中形成一个函数链;
    从pthread_cleanup_push的调用点到pthread_cleanup_pop之间的程序段中的终止动作(包括调用pthread_exit()和异常终止,不包括return)
    都将执行pthread_cleanup_push()所指定的清理函数。

    在执行该函数链时按照压栈的相反顺序弹出。execute参数表示执行到 pthread_cleanup_pop()时
    是否在弹出清理函数的同时执行该函数,为0表示不执行,非0为执行;这个参数并不影响异常终止时清理函数的执行。

    线程同步与互斥
      线程共享进程的资源和地址空间,对这些资源进行操作时,必须考虑线程间同步与互斥问题
      三种线程同步机制
       •互斥锁 同时可用的资源是惟一的情况
       •信号量 同时可用的资源为多个的情况
       •条件变量
    互斥锁
      用简单的加锁方法控制对共享资源的原子操作
      只有两种状态: 上锁、解锁
    可把互斥锁看作某种意义上的全局变量
      在同一时刻只能有一个线程掌握某个互斥锁,拥有上锁状态的线程能够对共享资源进行操作
      若其他线程希望上锁一个已经被上锁的互斥锁,则该线程就会挂起,直到上锁的线程释放掉互斥锁为止
    互斥锁保证让每个线程对共享资源按顺序进行原子操作

    互斥锁分类
      快速互斥锁
        •调用线程会阻塞直至拥有互斥锁的线程解锁为止,默认为快速互斥锁
      检错互斥锁
        •为快速互斥锁的非阻塞版本,它会立即返回并返回一个错误信息

    互斥锁主要包括下面的基本函数:
      互斥锁初始化:pthread_mutex_init()
      互斥锁上锁:pthread_mutex_lock()
      互斥锁判断上锁:pthread_mutex_trylock()
      互斥锁解锁:pthread_mutex_unlock()
      消除互斥锁:pthread_mutex_destroy()

     #include <stdio.h>

    #include <stdlib.h>
    #include <pthread.h>

    #define THREAD_NUM 3
    #define REPEAT_TIMES 5
    #define DELAY 4

    pthread_mutex_t mutex;

    void *thrd_func(void *arg);

    int main(){
        pthread_t thread[THREAD_NUM];
        int no;
        void *tret;
        
        srand((int)time(0));
           // 创建快速互斥锁(默认),锁的编号返回给mutex    
        pthread_mutex_init(&mutex,NULL);

        // 创建THREAD_NUM个线程,每个线程号返回给&thread[no],每个线程的入口函数均为thrd_func,参数为
        for(no=0;no<THREAD_NUM;no++){
            if (pthread_create(&thread[no],NULL,thrd_func,(void*)no)!=0) {
                printf("Create thread %d error! ",no);
                exit(1);
            } else
                printf("Create thread %d success! ",no);
        }
        
        // 对每个线程进行join,返回值给tret
        for(no=0;no<THREAD_NUM;no++){
            if (pthread_join(thread[no],&tret)!=0){
                printf("Join thread %d error! ",no);
                exit(1);
            }else
                printf("Join thread %d success! ",no);
        }
        // 消除互斥锁
        pthread_mutex_destroy(&mutex);
        return 0;
    }

    void *thrd_func(void *arg){
        int thrd_num=(void*)arg; // 传入的参数,互斥锁的编号
        int delay_time,count; 
        
        // 对互斥锁上锁
        if(pthread_mutex_lock(&mutex)!=0) {
            printf("Thread %d lock failed! ",thrd_num);
            pthread_exit(NULL);
        }

        printf("Thread %d is starting. ",thrd_num);
        for(count=0;count<REPEAT_TIMES;count++) {
            delay_time=(int)(DELAY*(rand()/(double)RAND_MAX))+1;
            sleep(delay_time);
            printf(" Thread %d:job %d delay =%d. ",thrd_num,count,delay_time);
        }

        printf("Thread %d is exiting. ",thrd_num);
        // 解锁
        pthread_mutex_unlock(&mutex);
        
        pthread_exit(NULL);
    }

     如果不加互斥锁,CPU会将三个线程随机顺序,每个线程执行一会,再换另一个。直到结束

    信号量
      操作系统中所用到的PV原子操作,广泛用于进程或线程间的同步与互斥
        •本质上是一个非负的整数计数器,被用来控制对公共资源的访问
      PV原子操作:对整数计数器信号量sem的操作
        •一次P操作使sem减一,而一次V操作使sem加一
        •进程(或线程)根据信号量的值来判断是否对公共资源具有访问权限
      –当信号量sem的值大于等于零时,该进程(或线程)具有公共资源的访问权限
      –当信号量sem的值小于零时,该进程(或线程)就将阻塞直到信号量sem的值大于等于0为止
     
    PV操作主要用于线程间的同步和互斥
      互斥,几个线程只设置一个信号量sem
      同步,会设置多个信号量,安排不同初值来实现它们之间的顺序执行
    信号量函数
      sem_init() 创建一个信号量,并初始化它
    int sem_init(sem_t *sem, int pshared, unsigned int value);
    sem_init() 初始化一个定位在 sem 的匿名信号量。value 参数指定信号量的初始值。 pshared 参数指明信号量是由进程内线程共享,还是由进程之间共享。如果 pshared 的值为 0,那么信号量将被进程内的线程共享,并且应该放置在这个进程的所有线程都可见的地址上(如全局变量,或者堆上动态分配的变量)。
    如果 pshared 是非零值,那么信号量将在进程之间共享,并且应该定位共享内存区域(见 shm_open(3)、mmap(2) 和 shmget(2))。(因为通过 fork(2) 创建的孩子继承其父亲的内存映射,因此它也可以见到这个信号量。所有可以访问共享内存区域的进程都可以用 sem_post(3)、sem_wait(3) 等等操作信号量。初始化一个已经初始的信号量其结果未定义。  

         sem_wait()和sem_trywait(): P操作,在信号量大于零时将信号量的值减一

        •区别: 若信号量小于零时,sem_wait()将会阻塞线程,sem_trywait()则会立即返回
      sem_post(): V操作,将信号量的值加一同时发出信号来唤醒等待的线程
      sem_getvalue(): 得到信号量的值
      sem_destroy(): 删除信号量

     #include <stdio.h>

    #include <stdlib.h>
    #include <pthread.h>
    #include <semaphore.h>

    #define THREAD_NUM 3
    #define REPEAT_TIMES 5
    #define DELAY 4

    sem_t sem[THREAD_NUM];

    void *thrd_func(void *arg);

    int main(){
        pthread_t thread[THREAD_NUM];
        int no;
        void *tret;
        
        srand((int)time(0)); 

        // 初始化THREAD_NUM-1个信号量,均初始化为0
        for(no=0;no<THREAD_NUM-1;no++){
            sem_init(&sem[no],0,0);
        }

        // sem[2]信号量初始化为1,即sem数组中最后一个信号量
        sem_init(&sem[2],0,1);
        
        // 创建THREAD_NUM个线程,入口函数均为thrd_func,参数为(void*)no
        for(no=0;no<THREAD_NUM;no++){
            if (pthread_create(&thread[no],NULL,thrd_func,(void*)no)!=0) {
                printf("Create thread %d error! ",no);
                exit(1);
            } else
                printf("Create thread %d success! ",no);
        }
        
        // 逐个join掉THREAD_NUM个线程
        for(no=0;no<THREAD_NUM;no++){
            if (pthread_join(thread[no],&tret)!=0){
                printf("Join thread %d error! ",no);
                exit(1);
            }else
                printf("Join thread %d success! ",no);
        }
        
        // 逐个取消信号量
        for(no=0;no<THREAD_NUM;no++){
            sem_destroy(&sem[no]);
        }

        return 0;
    }

    void *thrd_func(void *arg){
        int thrd_num=(void*)arg; // 参数no
        int delay_time,count;

        // 带有阻塞的p操作
        sem_wait(&sem[thrd_num]);

        
        printf("Thread %d is starting. ",thrd_num);
        for(count=0;count<REPEAT_TIMES;count++) {
            delay_time=(int)(DELAY*(rand()/(double)RAND_MAX))+1;
            sleep(delay_time);
            printf(" Thread %d:job %d delay =%d. ",thrd_num,count,delay_time);
        }

        printf("Thread %d is exiting. ",thrd_num);
        
        // 对前一个信号量进行V操作
        
    // 由于只有最后一个信号量初始化为1,其余均为0
        
    // 故线程执行的顺序将为逆序
        sem_post(&sem[(thrd_num+THREAD_NUM-1)%THREAD_NUM]);

        pthread_exit(NULL); // 线程主动结束
    }
  • 相关阅读:
    Linux下使用Nexus搭建Maven私服
    使用Nexus搭建Maven内部服务器
    windows Maven3.0 服务器配置搭建
    Linux中more和less命令用法
    Jmeter使用入门
    【转载】 DeepMind发表Nature子刊新论文:连接多巴胺与元强化学习的新方法
    【转载】 十图详解tensorflow数据读取机制(附代码)
    【转载】 tensorflow中 tf.train.slice_input_producer 和 tf.train.batch 函数
    (待续) https://zhuanlan.zhihu.com/p/27629294
    ( 待续 ) https://zhuanlan.zhihu.com/p/57864886
  • 原文地址:https://www.cnblogs.com/liuchengchuxiao/p/4232503.html
Copyright © 2011-2022 走看看