通用线程:POSIX 线程详解,第 3 部分

通用线程:POSIX 线程详解,第 3 部分

使用条件变量提升效率

Daniel Robbins, 总裁兼 CEO, Gentoo Technologies, Inc.

简介: 本文是 POSIX 线程三部曲系列的最后一部分,Daniel 将详细讨论如何使用条件变量。条件变量是 POSIX 线程结构,可让您在遇到某些条件时“唤醒”线程。能够将它们看做是一种线程安全的信号发送。Daniel 使用目前您所学到的知识实现了一个多线程工做组应用程序,本文将围绕着这一示例而进行讨论。html

条件变量详解node

上一篇文章结束时,我描述了一个比较特殊的难题:若是线程正在等待某个特定条件发生,它应该如何处理这种状况?它能够重复对互斥对象锁定和解锁,每次都会检查共享数据结构,以查找某个值。但这是在浪费时间和资源,并且这种繁忙查询的效率很是低。解决这个问题的最佳方法是使用 pthread_cond_wait() 调用来等待特殊条件发生。 linux

了解 pthread_cond_wait() 的做用很是重要 -- 它是 POSIX 线程信号发送系统的核心,也是最难以理解的部分。安全

首先,让咱们考虑如下状况:线程为查看已连接列表而锁定了互斥对象,然而该列表恰巧是空的。这一特定线程什么也干不了 -- 其设计意图是从列表中除去节点,可是如今却没有节点。所以,它只能:数据结构

锁定互斥对象时,线程将调用 pthread_cond_wait(&mycond,&mymutex)。pthread_cond_wait() 调用至关复杂,所以咱们每次只执行它的一个操做。多线程

pthread_cond_wait() 所作的第一件事就是同时对互斥对象解锁(因而其它线程能够修改已连接列表),并等待条件 mycond 发生(这样当 pthread_cond_wait() 接收到另外一个线程的“信号”时,它将苏醒)。如今互斥对象已被解锁,其它线程能够访问和修改已连接列表,可能还会添加项。app

此时,pthread_cond_wait() 调用还未返回。对互斥对象解锁会当即发生,但等待条件 mycond 一般是一个阻塞操做,这意味着线程将睡眠,在它苏醒以前不会消耗 CPU 周期。这正是咱们期待发生的状况。线程将一直睡眠,直到特定条件发生,在这期间不会发生任何浪费 CPU 时间的繁忙查询。从线程的角度来看,它只是在等待 pthread_cond_wait() 调用返回。ide

如今继续说明,假设另外一个线程(称做“2 号线程”)锁定了 mymutex 并对已连接列表添加了一项。在对互斥对象解锁以后,2 号线程会当即调用函数 pthread_cond_broadcast(&mycond)。此操做以后,2 号线程将使全部等待 mycond 条件变量的线程当即苏醒。这意味着第一个线程(仍处于 pthread_cond_wait() 调用中)如今将苏醒。函数

如今,看一下第一个线程发生了什么。您可能会认为在 2 号线程调用 pthread_cond_broadcast(&mymutex) 以后,1 号线程的 pthread_cond_wait() 会当即返回。不是那样!实际上,pthread_cond_wait() 将执行最后一个操做:从新锁定 mymutex。一旦 pthread_cond_wait() 锁定了互斥对象,那么它将返回并容许 1 号线程继续执行。那时,它能够立刻检查列表,查看它所感兴趣的更改。学习

中止并回顾!

那个过程很是复杂,所以让咱们先来回顾一下。第一个线程首先调用:

    pthread_mutex_lock(&mymutex);

而后,它检查了列表。没有找到感兴趣的东西,因而它调用:

    pthread_cond_wait(&mycond, &mymutex);

而后,pthread_cond_wait() 调用在返回前执行许多操做:

       
    pthread_mutex_unlock(&mymutex);

它对 mymutex 解锁,而后进入睡眠状态,等待 mycond 以接收 POSIX 线程“信号”。一旦接收到“信号”(加引号是由于咱们并非在讨论传统的 UNIX 信号,而是来自 pthread_cond_signal() 或 pthread_cond_broadcast() 调用的信号),它就会苏醒。但 pthread_cond_wait() 没有当即返回 -- 它还要作一件事:从新锁定 mutex:

     
    pthread_mutex_lock(&mymutex);

pthread_cond_wait() 知道咱们在查找 mymutex “背后”的变化,所以它继续操做,为咱们锁定互斥对象,而后才返回。

pthread_cond_wait() 小测验

如今已回顾了 pthread_cond_wait() 调用,您应该了解了它的工做方式。应该可以叙述 pthread_cond_wait() 依次执行的全部操做。尝试一下。若是理解了 pthread_cond_wait(),其他部分就至关容易,所以请从新阅读以上部分,直到记住为止。好,读完以后,可否告诉我在调用 pthread_cond_wait() 之 ,互斥对象必须处于什么状态?pthread_cond_wait() 调用返回以后,互斥对象处于什么状态?这两个问题的答案都是“锁定”。既然已经彻底理解了 pthread_cond_wait() 调用,如今来继续研究更简单的东西 -- 初始化和真正的发送信号和广播进程。到那时,咱们将会对包含了多线程工做队列的 C 代码了如指掌。

初始化和清除

条件变量是一个须要初始化的真实数据结构。如下就初始化的方法。首先,定义或分配一个条件变量,以下所示:

    pthread_cond_t mycond;

而后,调用如下函数进行初始化:

    pthread_cond_init(&mycond,NULL);

瞧,初始化完成了!在释放或废弃条件变量以前,须要毁坏它,以下所示:

    pthread_cond_destroy(&mycond);

很简单吧。接着讨论 pthread_cond_wait() 调用。

等待

一旦初始化了互斥对象和条件变量,就能够等待某个条件,以下所示:

    pthread_cond_wait(&mycond, &mymutex);

请注意,代码在逻辑上应该包含 mycond 和 mymutex。一个特定条件只能有一个互斥对象,并且条件变量应该表示互斥数据“内部”的一种特殊的条件更改。一个互斥对象能够用许多条件变量(例如,cond_empty、cond_full、cond_cleanup),但每一个条件变量只能有一个互斥对象。

发送信号和广播

对于发送信号和广播,须要注意一点。若是线程更改某些共享数据,并且它想要唤醒全部正在等待的线程,则应使用 pthread_cond_broadcast 调用,以下所示:

    pthread_cond_broadcast(&mycond);

在某些状况下,活动线程只须要唤醒第一个正在睡眠的线程。假设您只对队列添加了一个工做做业。那么只须要唤醒一个工做程序线程(再唤醒其它线程是不礼貌的!):

    pthread_cond_signal(&mycond);

此函数只唤醒一个线程。若是 POSIX 线程标准容许指定一个整数,可让您唤醒必定数量的正在睡眠的线程,那就更完美了。可是很惋惜,我没有被邀请参加会议。

工做组

我将演示如何建立多线程工做组。在这个方案中,咱们建立了许多工做程序线程。每一个线程都会检查 wq(“工做队列”),查看是否有须要完成的工做。若是有须要完成的工做,那么线程将从队列中除去一个节点,执行这些特定工做,而后等待新的工做到达。

与此同时,主线程负责建立这些工做程序线程、将工做添加到队列,而后在它退出时收集全部工做程序线程。您将会遇到许多 C 代码,好好准备吧!

队列

须要队列是出于两个缘由。首先,须要队列来保存工做做业。还须要可用于跟踪已终止线程的数据结构。还记得前几篇文章(请参阅本文结尾处的 参考资料)中,我曾提到过须要使用带有特定进程标识的 pthread_join 吗?使用“清除队列”(称做 "cq")能够解决没法等待 任何已终止线程的问题(稍后将详细讨论这个问题)。如下是标准队列代码。将此代码保存到文件 queue.h 和 queue.c:


queue.h
/* queue.h
** Copyright 2000 Daniel Robbins, Gentoo Technologies, Inc.
** Author: Daniel Robbins
** Date: 16 Jun 2000
*/
typedef struct node {
  struct node *next;
} node;
typedef struct queue {
  node *head, *tail; 
} queue;
void queue_init(queue *myroot);
void queue_put(queue *myroot, node *mynode);
node *queue_get(queue *myroot);



queue.c
/* queue.c
** Copyright 2000 Daniel Robbins, Gentoo Technologies, Inc.
** Author: Daniel Robbins
** Date: 16 Jun 2000
**
** This set of queue functions was originally thread-aware.  I
** redesigned the code to make this set of queue routines
** thread-ignorant (just a generic, boring yet very fast set of queue
** routines).  Why the change?  Because it makes more sense to have
** the thread support as an optional add-on.  Consider a situation
** where you want to add 5 nodes to the queue.  With the
** thread-enabled version, each call to queue_put() would
** automatically lock and unlock the queue mutex 5 times -- that's a
** lot of unnecessary overhead.  However, by moving the thread stuff
** out of the queue routines, the caller can lock the mutex once at
** the beginning, then insert 5 items, and then unlock at the end.
** Moving the lock/unlock code out of the queue functions allows for
** optimizations that aren't possible otherwise.  It also makes this
** code useful for non-threaded applications.
**
** We can easily thread-enable this data structure by using the
** data_control type defined in control.c and control.h.  */
#include <stdio.h>
#include "queue.h"
void queue_init(queue *myroot) {
  myroot->head=NULL;
  myroot->tail=NULL;
}
void queue_put(queue *myroot,node *mynode) {
  mynode->next=NULL;
  if (myroot->tail!=NULL)
    myroot->tail->next=mynode;
  myroot->tail=mynode;
  if (myroot->:head==NULL)
    myroot->head=mynode;
}
node *queue_get(queue *myroot) {
  //get from root
  node *mynode;
  mynode=myroot->head;
  if (myroot->head!=NULL)
    myroot->head=myroot->head->next;
  return mynode;
}

data_control 代码

我编写的并非线程安全的队列例程,事实上我建立了一个“数据包装”或“控制”结构,它能够是任何线程支持的数据结构。看一下 control.h:


control.h
#include 
typedef struct data_control {
  pthread_mutex_t mutex;
  pthread_cond_t cond;
  int active;
} data_control;

如今您看到了 data_control 结构定义,如下是它的视觉表示:


所使用的 data_control 结构

图像中的锁表明互斥对象,它容许对数据结构进行互斥访问。黄色的星表明条件变量,它能够睡眠,直到所讨论的数据结构改变为止。on/off 开关表示整数 "active",它告诉线程此数据是不是活动的。在代码中,我使用整数 active 做为标志,告诉工做队列什么时候应该关闭。如下是 control.c:


control.c
/* control.c
** Copyright 2000 Daniel Robbins, Gentoo Technologies, Inc.
** Author: Daniel Robbins
** Date: 16 Jun 2000
**
** These routines provide an easy way to make any type of
** data-structure thread-aware.  Simply associate a data_control
** structure with the data structure (by creating a new struct, for
** example).  Then, simply lock and unlock the mutex, or
** wait/signal/broadcast on the condition variable in the data_control
** structure as needed.
**
** data_control structs contain an int called "active".  This int is
** intended to be used for a specific kind of multithreaded design,
** where each thread checks the state of "active" every time it locks
** the mutex.  If active is 0, the thread knows that instead of doing
** its normal routine, it should stop itself.  If active is 1, it
** should continue as normal.  So, by setting active to 0, a
** controlling thread can easily inform a thread work crew to shut
** down instead of processing new jobs.  Use the control_activate()
** and control_deactivate() functions, which will also broadcast on
** the data_control struct's condition variable, so that all threads
** stuck in pthread_cond_wait() will wake up, have an opportunity to
** notice the change, and then terminate.
*/
#include "control.h"
int control_init(data_control *mycontrol) {
  int mystatus;
  if (pthread_mutex_init(&(mycontrol->mutex),NULL))
    return 1;
  if (pthread_cond_init(&(mycontrol->cond),NULL))
    return 1;
  mycontrol->active=0;
  return 0;
}
int control_destroy(data_control *mycontrol) {
  int mystatus;
  if (pthread_cond_destroy(&(mycontrol->cond)))
    return 1;
  if (pthread_cond_destroy(&(mycontrol->cond)))
    return 1;
  mycontrol->active=0;
  return 0;
}
int control_activate(data_control *mycontrol) {
  int mystatus;
  if (pthread_mutex_lock(&(mycontrol->mutex)))
    return 0;
  mycontrol->active=1;
  pthread_mutex_unlock(&(mycontrol->mutex));
  pthread_cond_broadcast(&(mycontrol->cond));
  return 1;
}
int control_deactivate(data_control *mycontrol) {
  int mystatus;
  if (pthread_mutex_lock(&(mycontrol->mutex)))
    return 0;
  mycontrol->active=0;
  pthread_mutex_unlock(&(mycontrol->mutex));
  pthread_cond_broadcast(&(mycontrol->cond));
  return 1;
}

调试时间

在开始调试以前,还须要一个文件。如下是 dbug.h:


dbug.h
#define dabort() \
 {  printf("Aborting at line %d in source file %s\n",__LINE__,__FILE__); abort(); }

此代码用于处理工做组代码中的不可纠正错误。

工做组代码

说到工做组代码,如下就是:


workcrew.c
#include <stdio.h>
#include <stdlib.h>
#include "control.h"
#include "queue.h"
#include "dbug.h"
/* the work_queue holds tasks for the various threads to complete. */
struct work_queue {
  data_control control;
  queue work;
} wq;
/* I added a job number to the work node.  Normally, the work node
   would contain additional data that needed to be processed. */
typedef struct work_node {
  struct node *next;
  int jobnum;
} wnode;
/* the cleanup queue holds stopped threads.  Before a thread
   terminates, it adds itself to this list.  Since the main thread is
   waiting for changes in this list, it will then wake up and clean up
   the newly terminated thread. */
struct cleanup_queue {
  data_control control;
  queue cleanup;
} cq;
/* I added a thread number (for debugging/instructional purposes) and
   a thread id to the cleanup node.  The cleanup node gets passed to
   the new thread on startup, and just before the thread stops, it
   attaches the cleanup node to the cleanup queue.  The main thread
   monitors the cleanup queue and is the one that performs the
   necessary cleanup. */
typedef struct cleanup_node {
  struct node *next;
  int threadnum;
  pthread_t tid;
} cnode;
void *threadfunc(void *myarg) {
  wnode *mywork;
  cnode *mynode;
  mynode=(cnode *) myarg;
  pthread_mutex_lock(&wq.control.mutex);
  while (wq.control.active) {
    while (wq.work.head==NULL && wq.control.active) {
      pthread_cond_wait(&wq.control.cond, &wq.control.mutex);
    }
    if (!wq.control.active) 
      break;
    //we got something!
    mywork=(wnode *) queue_get(&wq.work);
    pthread_mutex_unlock(&wq.control.mutex);
    //perform processing...
    printf("Thread number %d processing job %d\n",mynode->threadnum,mywork->jobnum);
    free(mywork);
    pthread_mutex_lock(&wq.control.mutex);
  }
  pthread_mutex_unlock(&wq.control.mutex);
  pthread_mutex_lock(&cq.control.mutex);
  queue_put(&cq.cleanup,(node *) mynode);
  pthread_mutex_unlock(&cq.control.mutex);
  pthread_cond_signal(&cq.control.cond);
  printf("thread %d shutting down...\n",mynode->threadnum);
  return NULL;
  
}
#define NUM_WORKERS 4
int numthreads;
void join_threads(void) {
  cnode *curnode;
  printf("joining threads...\n");
  while (numthreads) {
    pthread_mutex_lock(&cq.control.mutex);
    /* below, we sleep until there really is a new cleanup node.  This
       takes care of any false wakeups... even if we break out of
       pthread_cond_wait(), we don't make any assumptions that the
       condition we were waiting for is true.  */
    while (cq.cleanup.head==NULL) {
      pthread_cond_wait(&cq.control.cond,&cq.control.mutex);
    }
    /* at this point, we hold the mutex and there is an item in the
       list that we need to process.  First, we remove the node from
       the queue.  Then, we call pthread_join() on the tid stored in
       the node.  When pthread_join() returns, we have cleaned up
       after a thread.  Only then do we free() the node, decrement the
       number of additional threads we need to wait for and repeat the
       entire process, if necessary */
      curnode = (cnode *) queue_get(&cq.cleanup);
      pthread_mutex_unlock(&cq.control.mutex);
      pthread_join(curnode->tid,NULL);
      printf("joined with thread %d\n",curnode->threadnum);
      free(curnode);
      numthreads--;
  }
}
int create_threads(void) {
  int x;
  cnode *curnode;
  for (x=0; x<NUM_WORKERS; x++) {
    curnode=malloc(sizeof(cnode));
    if (!curnode)
      return 1;
    curnode->threadnum=x;
    if (pthread_create(&curnode->tid, NULL, threadfunc, (void *) curnode))
      return 1;
    printf("created thread %d\n",x);
    numthreads++;
  }
  return 0;
}
void initialize_structs(void) {
  numthreads=0;
  if (control_init(&wq.control))
    dabort();
  queue_init(&wq.work);
  if (control_init(&cq.control)) {
    control_destroy(&wq.control);
    dabort();
  }
  queue_init(&wq.work);
  control_activate(&wq.control);
}
void cleanup_structs(void) {
  control_destroy(&cq.control);
  control_destroy(&wq.control);
}
int main(void) {
  int x;
  wnode *mywork;
  initialize_structs();
  /* CREATION */
  
  if (create_threads()) {
    printf("Error starting threads... cleaning up.\n");
    join_threads();
    dabort();
  }
  pthread_mutex_lock(&wq.control.mutex);
  for (x=0; x<16000; x++) {
    mywork=malloc(sizeof(wnode));
    if (!mywork) {
      printf("ouch! can't malloc!\n");
      break;
    }
    mywork->jobnum=x;
    queue_put(&wq.work,(node *) mywork);
  }
  pthread_mutex_unlock(&wq.control.mutex);
  pthread_cond_broadcast(&wq.control.cond);
  printf("sleeping...\n");
  sleep(2);
  printf("deactivating work queue...\n");
  control_deactivate(&wq.control);
  /* CLEANUP  */
  join_threads();
  cleanup_structs();
}

代码初排

如今来快速初排代码。定义的第一个结构称做 "wq",它包含了 data_control 和队列头。data_control 结构用于仲裁对整个队列的访问,包括队列中的节点。下一步工做是定义实际的工做节点。要使代码符合本文中的示例,此处所包含的都是做业号。

接着,建立清除队列。注释说明了它的工做方式。好,如今让咱们跳过 threadfunc()、join_threads()、create_threads() 和 initialize_structs() 调用,直接跳到 main()。所作的第一件事就是初始化结构 -- 这包括初始化 data_controls 和队列,以及激活工做队列。

有关清除的注意事项

如今初始化线程。若是看一下 create_threads() 调用,彷佛一切正常 -- 除了一件事。请注意,咱们正在分配清除节点,以及初始化它的线程号和 TID 组件。咱们还将清除节点做为初始自变量传递给每个新的工做程序线程。为何这样作?

由于当某个工做程序线程退出时,它会将其清除节点链接到清除队列,而后终止。那时,主线程会在清除队列中检测到这个节点(利用条件变量),并将这个节点移出队列。由于 TID(线程标识)存储在清除节点中,因此主线程能够确切知道哪一个线程已终止了。而后,主线程将调用 pthread_join(tid),并联接适当的工做程序线程。若是没有作记录,那么主线程就须要按任意顺序联接工做程序线程,多是按它们的建立顺序。因为线程不必定按此顺序终止,那么主线程可能会在已经联接了十个线程时,等待联接另外一个线程。您能理解这种设计决策是如何使关闭代码加速的吗(尤为在使用几百个工做程序线程的状况下)?

建立工做

咱们已启动了工做程序线程(它们已经完成了执行 threadfunc(),稍后将讨论此函数),如今主线程开始将工做节点插入工做队列。首先,它锁定 wq 的控制互斥对象,而后分配 16000 个工做包,将它们逐个插入队列。完成以后,将调用 pthread_cond_broadcast(),因而全部正在睡眠的线程会被唤醒,并开始执行工做。此时,主线程将睡眠两秒钟,而后释放工做队列,并通知工做程序线程终止活动。接着,主线程会调用 join_threads() 函数来清除全部工做程序线程。

threadfunc()

如今来讨论 threadfunc(),这是全部工做程序线程都要执行的代码。当工做程序线程启动时,它会当即锁定工做队列互斥对象,获取一个工做节点(若是有的话),而后对它进行处理。若是没有工做,则调用 pthread_cond_wait()。您会注意到这个调用在一个很是紧凑的 while() 循环中,这是很是重要的。当从 pthread_cond_wait() 调用中苏醒时,决不能认为条件确定发生了 -- 它 可能发生了,也可能没有发生。若是发生了这种状况,即错误地唤醒了线程,而列表是空的,那么 while 循环将再次调用 pthread_cond_wait()。

若是有一个工做节点,那么咱们只打印它的做业号,释放它并退出。然而,实际代码会执行一些更实质性的操做。在 while() 循环结尾,咱们锁定了互斥对象,以便检查 active 变量,以及在循环顶部检查新的工做节点。若是执行完此代码,就会发现若是 wq.control.active 是 0,while 循环就会终止,并会执行 threadfunc() 结尾处的清除代码。

工做程序线程的清除代码部件很是有趣。首先,因为 pthread_cond_wait() 返回了锁定的互斥对象,它会对 work_queue 解锁。而后,它锁定清除队列,添加清除代码(包含了 TID,主线程将使用此 TID 来调用 pthread_join()),而后再对清除队列解锁。此后,它发信号给全部 cq 等待者 (pthread_cond_signal(&cq.control.cond)),因而主线程就知道有一个待处理的新节点。咱们不使用 pthread_cond_broadcast(),由于没有这个必要 -- 只有一个线程(主线程)在等待清除队列中的新节点。当它调用 join_threads() 时,工做程序线程将打印关闭消息,而后终止,等待主线程发出的 pthread_join() 调用。

join_threads()

若是要查看关于如何使用条件变量的简单示例,请参考 join_threads() 函数。若是还有工做程序线程,join_threads() 会一直执行,等待清除队列中新的清除节点。若是有新节点,咱们会将此节点移出队列、对清除队列解锁(从而使工做程序能够添加清除节点)、联接新的工做程序线程(使用存储在清除节点中的 TID)、释放清除节点、减小“现有”线程的数量,而后继续。

结束语

如今已经到了“POSIX 线程详解”系列的尾声,但愿您已经准备好开始将多线程代码添加到您本身的应用程序中。有关详细信息,请参阅 参考资料部分,这部份内容还包含了本文中使用的全部源码的 tar 文件。下一个系列中再见!


参考资料

  • 您能够参阅本文在 developerWorks 全球站点上的 英文原文.

  • 本文中使用的 源码的 tar 文件

  • 友好的 Linux pthread 在线帮助 ("man -k pthread") 是极好的参考资料。

  • 若是要完全了解 POSIX 线程,我推荐此书: Programming with POSIX Threads ,David R. Butenhof (Addison-Wesley, 1997)。据证明,此书是现有最好的讨论 POSIX 线程的书籍。

  • W. Richard Stevens 撰写的 UNIX Network Programming - Networking APIs: Sockets and XTI ,(Prentice Hall, 1997) 一书还涵盖了 POSIX 线程。这是一本经典著做,但它讨论线程不如上述的 Programming with POSIX Threads那样详细。

  • 请参考 Daniel 在 developerWorks上发表的 POSIX 线程系列中的前几篇文章:

  • 请参阅 Sean Walton 撰写的有关 Linux 线程的文档,KB7rfa

  • 请学习亚里桑那大学的 Mark Hays 编写的 POSIX 线程 教程

  • 请在 Pthreads-Tcl 介绍中查看对 Tcl 的更改,此更改使 Tcl 可以与 POSIX 线程一块儿使用。

  • 请访问 LINUX POSIX 和 DCE 线程主页。

  • 请参阅 LinuxThreads 资料库

  • Proolix是一种简单的听从 POSIX 标准的基于 i8086+ 的操做系统。

关于做者

Daniel Robbins 居住在新墨西哥州的 Albuquerque。他是 Gentoo Technologies, Inc. 的总裁兼 CEO,Gentoo 项目的总设计师,MacMillan 出版书籍的撰稿做者,他的著做有: Caldera OpenLinux Unleashed, SuSE Linux Unleashed, 和 Samba Unleashed。Daniel 自二年级起就与计算机某些领域结下不解之缘,那时他首先接触的是 Logo 程序语言,并沉溺于 Pac-Man 游戏中。这也许就是他至今仍担任 SONY Electronic Publishing/Psygnosis 的首席图形设计师的缘由所在。Daniel 喜欢与妻子 Mary 和新出生的女儿 Hadassah 一块儿共度时光。可经过 drobbins@gentoo.org与 Daniel 联系。


原文地址:http://www.ibm.com/developerworks/cn/linux/thread/posix_thread3/index.html#ibm-pcon

相关文章
相关标签/搜索