首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Linux 线程控制的内功心法:详解 pthread 库函数背后的底层逻辑,手把手教你掌握线程生命周期的“生杀大权”

Linux 线程控制的内功心法:详解 pthread 库函数背后的底层逻辑,手把手教你掌握线程生命周期的“生杀大权”

作者头像
海棠蚀omo
发布2026-01-12 16:35:45
发布2026-01-12 16:35:45
1220
举报

前言: 通过上一篇的初识线程的相关内容,我们此时对于线程已经有了大致的了解,而我们今天这篇就要深入线程,了解关于线程控制的相关知识,在今天这篇中我们会看到很多进程身上的影子,那么下面就跟我来看看吧!!!

一.Linux进程VS线程 -- 哪些资源共享,哪些独占

在讲解下面的内容之前,我们对于进程和线程要先达成下面的两点共识:

进程间具有独⽴性 线程共享地址空间,也就共享进程资源

经过上一篇内容的讲解后,相信大家对于这两句话应该都能理解,这里就不再过多赘述了。

2.1进程和线程

经过上一篇的讲解后,我们知道:进程是资源分配的基本单位,线程是调度的基本单位。而线程虽然共享进程数据,但是也拥有自己的一部分" 私有 "数据:

线程ID ⼀组寄存器,线程的上下⽂数据 errno 信号屏蔽字 调度优先级

线程ID想必不用多介绍,每个线程都有属于自己的ID,也就是lwp。

而我们上面也说了,线程是调度的基本单位,那么不同的线程在被调度时,执行的是不同的函数,进而在cpu中寄存器中所形成的上下文数据也是不一样的

并且每个线程在执行自己的函数时,在函数中可能会定义一些局部变量等,而这些局部变量在栈中的位置只有当前的线程知道,其他线程是不知道的,所以我们也可以认为有一部分的栈是线程" 私有 "的。

而在上面所列举的这几种情况里面,我们最需要记忆的就是上面所介绍的三种,另外三种我们不记都可以,这三种是一定要牢记的。

2.2进程的多个线程共享

同⼀地址空间,因此 数据段,代码段都是共享的,如果定义⼀个函数,在各线程中都可以调⽤,如果定义⼀个全局变量,在各线程中都可以访问到 ,除此之外,各线程还共享以下进程资源和环境:

⽂件描述符表 每种信号的处理⽅式(SIG_ IGN、SIG_ DFL或者⾃定义的信号处理函数) 当前⼯作⽬录 ⽤⼾id和组id

没错,文件描述符表也是共享的,因为进程在被创建时是只有一个主线程的,最初的文件描述符表也就在这个主线程中。

而我们在后面创建的新的线程,其实 都是按照以主线程为模板来创建的 ,这就和父子进程的PCB是一样的道理,只修改其中的部分属性,其余都是保持不变的。

所以 每个线程的相关指针都指向的是主线程中的文件描述表,即所有的线程共享文件描述符表!!!

所以进程和线程的关系如下图:

二.Linux线程控制

2.1创建线程

关于创建线程的函数:pthread_create我们在上一篇中就已经介绍过,所以大家并不陌生。

但是对于这个函数的第一个参数我们在上一篇中只是一句话带过,只说它是一个输出型参数,会带出来一个id,那么我们来思考一下:这个id是线程的lwp吗?

要想知道问题的答案,我们来试验一下就知道了:

代码语言:javascript
复制
void *thread_routine(void *args)
{
    string thread_name = (const char *)args;
    while(true)
    {
        printf("new thread ...\n");
        sleep(1);
    }
    return nullptr;
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, thread_routine, (void *)"thread-1");
    (void)n;

    while (true)
    {
        printf("main thread ..., new thread id : %ld\n",tid);
        sleep(1);
    }
    return 0;
}

从结果中我们可以看到这个数很大,明显不是lwp,lwp的形式我们之前见过,没有这么大,我们下面将其转化为16进制再看一次:

转成16进制后,我们再看这个值,是否感觉它很像某个地址呢?

没错,这个输出型参数所带出来的值正是一个地址,而至于这个地址是谁的地址在下面我们就会知道了。

而接下来我们就来验证一下上面所说的线程所共享的全局变量,函数等内容。

1.全局变量

这里我就定义了一个全局变量,并在新线程所执行的函数中对其进行修改,如果这个全局变量是共享的话,即使新线程对page值进行修改,但是它们看到的是同一个虚拟地址空间中的同一块区域,主线程和新线程所看到的page变量的值和地址应当是一样的

而将上面的代码执行后,结果也正如我们所料,主线程和新线程看到的page变量的值和地址是一样的,并且主线程也能同步看到page值被新线程修改的过程

从上面的例子中我们验证了全局变量是所有线程共享的,下面我们接着看。

2.函数共享

代码语言:javascript
复制
string fun()
{
    return "我是一个新函数!";
}

上面我定义了一个全局函数,并让主线程和新线程同时去调用该函数,从结果中我们可以看到,两个线程都成功调用了该函数。

所以,我们此时也验证了:多线程其他函数共享!!!

3.堆空间共享

在上面的例子中我定义了一个int*类型的指针_data,并在新线程中在堆空间中开辟了一块空间,让其指向这块空间。和上面一样,如果堆空间是共享的话,那么新线程在堆上开辟的空间主线程也同样能看到

上面的运行结果也正如我们所料,主线程和新线程都能看到在堆上开辟的这块空间,自然也就能看到这块空间中的内容了,成功打印出了该空间中存储的内容。

2.2线程等待

我们来看一种情况,这里我让主线程的while只循环一次就直接停止,而不改变新线程中的内容,那么会发生什么呢?下面我们来看一看:

从结果中我们可以看到,当主线程退出后,新线程随之也被迫停止了,我们之前说过:主线程的main函数退出就意味着进程的结束

进程都结束了,里面的代码和数据都被释放了,其余线程还怎么执行呢?所以也都被迫停止了,那这是我们想看到的现象吗? 当然不是,我们想看到的现象应该是其余线程都退出后,主线程再退出,就和父子进程一样,子进程退出完了,父进程才退出,父进程要等待子进程,不然就会导致僵尸问题,而上面的主线程如果先退出了,没有等待新线程,那么新线程同样会出现类似" 僵尸问题 "的现象!!!

所以我们解决的方法就是让主线程来等待新线程,该如何做呢?我们来看:

既然进程有waitpid,那么我们线程自然也有我们的:pthread_join函数,这个函数的作用就是等待,让主线程调用该函数就可以让其等待所有的子线程了。

而它的返回值也相对简单,和上面的pthread_create函数一样,成功了就返回0,失败就返回错误码

下面我们来看它的两个参数,对于第一个参数我们并不陌生,和pthread_create的第一个参数很相似,pthread_create中的该参数传的是指针,这里是直接将该变量传进去,所以这个变量我们在传参时就是将我们最初定义的pthread_t变量传进去即可。 对于第二个参数就有的说了,我们让新线程去执行任务,我们要不要知道新线程将任务执行的怎么样呢? 当然要,而我们看到这个参数的类型是void**,而新线程执行的函数的返回值是void*,二级指针可以用来保存一级指针的内容,所以我们不难猜测这个参数指向的就是函数的返回值,也就是新线程退出的退出信息!!!

但是相信大家心中还有疑惑,比如:pthread_join函数是如何拿到线程的退出信息的呢?,函数的退出信息都有哪些呢?,这些问题我们放在后面一块讲,这里我们先不关心新线程的退出信息,传入nullptr即可。

在有了上面的pthread_join函数后,下面我们来看一个例子:

在上面的例子中我们创建了多个线程,并给每个线程起个新名字,以此来打印出每个线程的信息。

但从结果我们可以看到,效果并没有如我们所想的输出的的每个线程的名字是从:1-5,而是有大量重复的名字,但是从我们的代码逻辑来看没有问题啊,那是出bug了? 其实并非bug,我们要知道,我们每次循环创建的这个数组都在栈的同一位置,那么将这个数组传给每个线程时,每个线程看到的都是同一个数组,也就是数组内容被新线程共享了!!!

但是如果只是共享了的话,那毕竟该数组的内容是在不断变化的,那理论上也还是能够完成上面的任务,那为何还是出现名字大量重复的问题呢? 我们要知道cpu的处理速度是很快的,我们想象一种极端情况就能理解了:当cpu执行完这5次循环,也就是此时的数组中的内容已经不会再变化了。 而那些线程还没有执行到函数的第一步操作,那么当线程去执行这些函数的时候,看到的是同一个数组,也是同样的内容,所以在这种情况下,就会导致每个线程的名字都是相通的!!!

也就是上面的问题是由两种因素所导致的,而只要一种不满足,我们就能看到正确的结果,所以该如何做呢?我们来看:

代码语言:javascript
复制
int gnum = 5;

void *thread_routine(void *args)
{
    string thread_name = (const char *)args;
    while (true)
    {
        printf("new thread is running, name is : %s\n", thread_name.c_str());
        sleep(1);
    }
    return nullptr;
}

int main()
{
    pthread_t tid;
    vector<pthread_t> tids;
    for (int i = 0; i < gnum; i++)
    {
        //char idbuffer[64]; 
        char* name=new char[64];
        snprintf(name, 64, "thread-%d", i + 1);
        int n = pthread_create(&tid, nullptr, thread_routine, (void *)name);
        (void)n;

        tids.push_back(tid);
    }

    for (auto &tid : tids)
    {
        printf("main create a new thread, new thread id is : 0x%lx\n", tid);
    }
e
    for (auto &e : tids)
    {
        pthread_join(e, nullptr);
        printf("thread end..., 退出的线程是: %lu\n", e);
    }
    return 0;
}

这里我采用的做法每次循环都在堆上开辟新的空间,这样每个线程看到的就不是同一块空间,而从上面的运行结果我们可以看到此时每个进程的名字就是我们所期望的了。

2.3线程终止

讲了线程创建,线程等待,那么我们还有什么没讲的呢?

没错,那就是线程终止,所以这部分我们就来探讨一下线程终止的相关知识,这点相对于进程终止就更为简单,好理解了,我们接着看。

其实线程终止,也就是线程退出,那么在上面的代码中,我在线程执行的函数中最后return nullptr来使表示线程执行完毕,也就是终止了,但这只是其中一种线程终止的方式,我们下面再来看一种:

进程有exit函数来使进程退出,而线程当然也有类似的函数,那就是:pthread_exit

这个函数参数同样是void* 的类型,类型和线程退出的类型是一样的,也就是这个参数就需要我们传线程退出的相关信息,就和exit(1),exit(2)等是一样的道理

那么我们下面就来看一下效果:

这里我们让每个线程只执行一次就直接退出,从上面的结果中我们可以看到主线程通过pthread_join函数成功等待了每个新线程。

上面演示的就是第二种线程终止的方法,那么有人可能会问:那还有有其他线程终止的方法吗?

有的兄弟,有的,我们来看:

这个函数就叫做:pthread_cancel,这个函数的作用就是向一个线程发送取消请求,也就是让当前的线程终止。

返回值也是一如既往的简单,成功的话就返回0,失败了就返回错误码,和前面的函数都是一样的。

至于参数是pthread_t的类型,这个参数想必就不用多介绍了,传入我们创建线程时的tid即可,调用该函数时就会目标进程发送取消请求。

说着这么多,下面我们来试验一下看一看:

代码语言:javascript
复制
void *thread_routine(void *args)
{
    string name = (const char *)args;
    while (true)
    {
        printf("new thread is running, name is : %s\n", name.c_str());
        sleep(1);
    }
    return nullptr;
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, thread_routine, (void *)"thread-1");
    (void)n;

    sleep(2);

    pthread_cancel(tid);

    int m = pthread_join(tid, nullptr);
    if (m == 0)
    {
        printf("new thread dead...\n");
    }

    sleep(3);

    return 0;
}

在上面的例子中我们让新线程在循环了两次过后,通过主线程新线程发送取消请求,从上图中我们可以看到新线程在循环了两次过后确实被主线程等待成功了,也就表明新线程确实被终止了。

以上就是我们常用的线程退出的三种方式,那么有人对于第三种方式可能会有疑惑:pthread_cancel函数能否让新线程来使用呢?

当然可以,不过要用到一个函数,我们来看:

我们要借助:pthread_self函数,这个函数的作用就是获取当前线程的ID,因为新线程中没有调用pthread_create函数,所以我们要想在新线程中使用该函数,就需要借助这个函数,将其作为参数传给pthread_cancel函数。

但是这种方式比较抽象,我自己正执行着,我又把自己给终止了,这种方式虽然可行,但是我们不这样去使用。

一般使用这种方式的最佳实践就是通过主线程来调用该函数向新线程发送取消请求!!!

2.4分离线程

默认情况下,新创建的线程是joinable的,线程退出后,需要对其进⾏pthread_join操作,否则⽆法释放资源,从⽽造成系统泄漏。 对于上面的这句话,我列举一种情景大家就能理解了:当我们在电脑上打开一个软件后,我们不关闭它,它会自己退出吗? 并不会,那也就说明 我们的软件本质就是一个死循环,而我们的操作可能就会让软件去创建新的线程,而当该线程没有被等待,该软件也没有结束时,当前线程的资源就不会被及时回收 。 只有一个线程可能没什么,但要是 不停的创建新的线程,且每个线程在结束后都没有被及时的回收 ,那么后果想必大家都清楚了。

但如果我们并不关⼼线程的返回值,那么join就是⼀种负担,这个时候,我们就可以告诉系统,当线程退出时,⾃动释放线程资源,那么我们该如何做呢?

这里我们就要用到一个函数: pthread_datach ,这个函数的 作用就是让指定的线程断开与其他线程的联系,包括主线程, 也就是分离线程 这样该函数自己执行完就自动会被系统回收资源了

该函数的返回值和上面是一样的,成功就返回0,失败了就返回错误码。

该函数的用法有两种,一种是:由目标线程调用,相当于自己分离自己,另一种就是:由其他线程调用该函数,分离目标线程

并且 一个线程是不能既joinable又分离的,这二者是冲突的,如果一个线程已经分离了,那么再去等待它就会等待失败 ,下面我用一个例子来为大家演示:

代码语言:javascript
复制
void *thread_routine(void *args)
{
    string name = (const char *)args;
    while (true)
    {
        printf("new thread...,name : %s\n", name.c_str());
        sleep(1);
    }
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, thread_routine, (void *)"thread-1");

    pthread_detach(tid);

    void *ret;
    int m = pthread_join(tid, &ret);
    if (m > 0)
    {
        cout << "join failure: " << strerror(m) << "m = " << m << endl;
    }
    return 0;
}

这里我在主线程中调用该函数,把目标线程给分离掉,从结果中我们可以看到,主线程等待新线程失败了,得到的错误码为22,说明上面我们的结论是正确的,一个线程不能既是joinable又是分离的。

至于线程自己分离自己,这里我就不再演示了,用法和这里是一样的。

三.线程的传参与返回值

3.1pthread_join的第二个参数问题

在这个话题中我们首先要谈的就是上面的pthread_join的第二个参数,在上面我们只说了这个参数是用来存储线程的退出信息的,是否真的如此呢?我们来看看:

在上面的例子中,我让新线程只执行一次循环就退出,并让父进程通过等待函数pthread_join来接收线程的退出信息,从结果中我们可以看到pthread_join的第二个参数确实存储的是线程的退出信息。

但是这里相信大家心中会有一个疑惑:上面我们获取的都是线程正常退出的信息,那需要考虑线程异常所导致的终止问题吗? 在进程中,进程的退出是分为正常退出和异常退出的,也就是进程的退出信息中是包含信号+退出码的,而从上面线程的退出信息中我们可以看到只有退出码,并没有看到信号的相关信息。 答案就是线程不需要,也没有必要记录信号的相关信息,因为线程如果出异常了,就会导致整个进程被终止,其中的代码和数据都被释放了,程序就没有机会执行pthread_join函数来获取线程的退出信息。

所以对于线程的退出而言,我们只关心正常退出的情况!!!

讲解了参数相关的问题,我们还有一个疑问:pthread_join函数是如何获得线程的退出信息的?

要解决这个问题,我们要先达成一些共识,我们来看:

fopen函数我们都不陌生,它是C标准库中的一个函数,它的返回值是FILE*,而FILE我们之前讲过,它是一个结构体,那我的问题是:这个结构体在哪儿呢?

这个问题我们也知道,是在fopen函数内部自己malloc出来的一个结构体对象,那么我们就可以认为这个结构体对象是库帮我申请的,它就在库内部!!!

进而我们就可以更进一步地认为:库本身可以帮我们维护结构体对象!!!

这个共识对于我们下面的理解至关重要,那么此时我问大家一个问题:我们创建线程时操作系统要干什么?

很明显,因为我们的线程的概念是基于pthread库的,所以我们要对线程操作操作系统就要把pthread库给加载到内存中,进而通过页表映射到虚拟地址空间的共享区中。

而我们上面说了库本身可以帮我们维护结构体对象,那么我的问题是:线程未来我们会创建很多,那么库是怎么帮我们维护它们的呢?

依旧是先描述,在组织,所以在库中:

就有一个描述线程的结构体,这个结构体中的具体内容我们现在并不知道,但是我们可以肯定的是这个结构体既然是描述线程的,那么就会有上图中的两种信息,分别是:线程的标识符lwp和线程的退出信息ret

而现在我们就可以回答一个问题了:pthread_create函数的第一个参数带出来的到底是什么? 我们上面说带出来的是一个地址,那么现在我就告诉你,这个地址就是上面的tcb结构体的地址!!!pthread_join函数之所以要传入pthread_t类型的参数,就是为了拿到这个地址,通过这个地址找到目标线程的tcb,进而从中获取到线程的退出信息!!!

讲到这里,我们就把上面的一些疑问给解决了。

3.2应用层面传参和返回值

在上面的演示中我们我们对于线程所执行的函数的参数以及返回值只是简单的内置类型,如:string,int类型,那么谁告诉你只能传这种参数呢?

下面就以一个简单的例子来打开一下我们的思路:

代码语言:javascript
复制
class Task
{
public:
    Task() : x(0), y(0), result(0), code(0)
    {
    }

    Task(int _x, int _y) : x(_x), y(_y), result(0), code(0)
    {
    }

    void Div()
    {
        if (y == 0)
        {
            code = 1;
            return;
        }

        result = x / y;
    }

    void Print()
    {
        cout << "result: " << result << "[" << code << "]" << endl;
    }

private:
    int x;
    int y;
    int result;
    int code;
};

void *thread_routine(void *args)
{
    Task *ret = (Task *)args;
    ret->Div();
    return ret;
}

int main()
{
    pthread_t tid;
    Task *task = new Task(20, 10);
    pthread_create(&tid, nullptr, thread_routine, task);

    void *cnt;
    pthread_join(tid, &cnt);
    Task *result = (Task *)cnt;
    result->Print();
    return 0;
}

在上面的例子中,我创建了一个Task类,并在main函数中创建了一个类对象,将其作为参数传给了线程要执行的函数,并在函数中调用了Task类的相关函数,并且把计算过后的类对象作为返回值给返回了。 并通过pthread_join函数来等待新线程,从中获得函数的退出信息,最终调用类内函数打印出结果

通过这种方式我们就能够知道:线程所执行函数的参数和返回值,可以是任何类型,类对象也是可以的!!!

这下我们其实也能理解为什么函数的参数和返回值类型都是void*了,就是为了能够接收和返回任意类型的数据!!!

四.进程地址空间分布

我们所使用的pthread库是一个动态库,加载到内存中,进而会映射到进程虚拟地址空间中的共享区中,而我们也知道,动态库也叫做共享库,那么何为共享库呢?

那就是它的代码可以被多个进程共享使用,而每个进程都会用pthread库来创建线程,而pthread库又会维护每个线程的描述结构体,所以我们此时就可以得出一个结论了:线程管理,是整个系统中的tcb结构体都由pthread库来管理!!!

所以这个pthread库加载到内存中不是专门给我加载的,而是给整个系统加载的!!!

那么该库映射到虚拟地址空间中的内容都有哪些呢?下面我们来看看:

我们之前讲了,pthread_create函数的第一个参数所带出来的是描述线程的结构体的地址,那么现在我们从上面的图中可以看到,pthread_t tid所指向的地址中不止有描述现成的结构题struct pthread,还有:线程局部存储,线程栈

下面我就简单介绍一下这三个部分。

4.1struct pthread

代码语言:javascript
复制
struct pthread
{
    /* Thread ID - which is also a 'is this thread descriptor (and
        therefore stack) used' flag. */
    pid_t tid;

    /* Process ID - thread group ID in kernel speak. */
    pid_t pid;

    /* True if the user provided the stack. */
    bool user_stack;

    /* The result of the thread function. */
    // 线程运⾏完毕,返回值就是void*, 最后的返回值就放在tcb中的该变量⾥⾯
    // 所以我们⽤pthread_join获取线程退出信息的时候,就是读取该结构体
    // 另外,要能理解线程执⾏流可以退出,但是tcb可以暂时保留,这句话
    void *result;

    // ⽤⼾指定的⽅法和参数
    void *(*start_routine) (void *);
    void *arg;
    
    // 线程⾃⼰的栈和⼤⼩
    void *stackblock;
    size_t stackblock_size;
}

上图就是我从Linux源码中截取的该结构体的一部分属性,这些都是我们所熟识的,如:tid

就是线程的ID,pid就是进程的ID,result就是线程的返回信息等等

里面的属性还有很多,这里就不一一列举了。

4.2线程栈

从上面struct pthread的结构体中我们也看到了有两个属性是来描述线程自己的栈和大小的:void * stackblock和size_t stackblock_size

没错,线程也有自己的的栈,这个栈区域是每个线程所私有的,别的线程是无法访问的

这就与我们最初讲线程时说的线程所私有的栈就对应上了,这个栈区域同样位于进程虚拟地址空间中的栈区域,一般紧挨着主线程的栈区域

4.3线程局部存储

这个知识点并不是重点,不过下面我还是通过一个例子来帮助大家简单了解一下这部分的内容:

代码语言:javascript
复制
__thread int page = 0;

void *thread_routine(void *args)
{
    string name = (const char *)args;
    while (true)
    {
        printf("new thread...,name : %s, page: %d,&page: %p\n", name.c_str(),page,&page);
        page++;
        sleep(1);
    }
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, thread_routine, (void *)"thread-1");

    while(true)
    {
        printf("main thread..., page: %d,&page: %p\n",page,&page);
        sleep(1);
    }
}

这个代码我们在上面讲解所有线程共享全局变量时就已经讲过,这里我仅仅只是在全局变量的前面加了一个__thread

但是我们再次执行这部分代码就发现明明从语法上看page是全局变量,但是此时的主线程和新线程所看到的page已经不是同一个了。

新线程打印出的page值一直在变化,而主线程的page之却一直是0,并且最关键的是两者打印出的page的地址都不一样

所以出现这种现象的原因是什么呢? 不用想都知道是这个__thread搞的鬼,那么这个__thread到底是什么作用呢? 其实这个__thread并不是C/C++中的某个关键字,而是gcc/g++这些编译器的内置选项,也就是出现上面现象的原因是编译器所导致的。 当编译在编译时发现有__thread,那么它就会将该变量给所有的线程都拷贝一份,拷贝到每个线程的TLS区域,这个区域通常位于线程控制块或线程栈的特定区域中

上面的技术我们就称之为:线程局部存储

以上就是Linux 线程控制的内功心法:详解 pthread 库函数背后的底层逻辑,手把手教你掌握线程生命周期的“生杀大权”的全部内容。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2026-01-12,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一.Linux进程VS线程 -- 哪些资源共享,哪些独占
    • 2.1进程和线程
    • 2.2进程的多个线程共享
  • 二.Linux线程控制
    • 2.1创建线程
    • 2.2线程等待
    • 2.3线程终止
    • 2.4分离线程
  • 三.线程的传参与返回值
    • 3.1pthread_join的第二个参数问题
    • 3.2应用层面传参和返回值
  • 四.进程地址空间分布
    • 4.1struct pthread
    • 4.2线程栈
    • 4.3线程局部存储
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档