OS_process_sync_producer&comsumer进程同步/互斥/信号量&管程:临界资源/临界区:生产者和消费者问题(缓冲池(缓冲区)问题)

OS_process_sync_producer&comsumer

reference

  • 操作系统v4(汤)

临界资源 (Critical Resouce)

许多硬件资源如打印机、磁带机等,都属于临界资源,诸进程间应采取互斥方式,实现对这种资源的共享。

下面我们将通过一个简单的例子来说明这一过程。

代码的划分

把一个访问临界资源的循环进程描述如下:

while(TURE){
进入区
临界区
退出区
剩余区
}
临界区(critical section)
  • 由前所述可知,不论是硬件临界资源还是软件临界资源,多个进程必须互斥地对它进行访问。
  • 人们把在每个进程中访问临界资源的那段代码称为临界区(critical section)
    • (换句话说),显然地,若能保证诸进程互斥地进入自己的临界区,便可实现诸进程对临界资源的互斥访问。
    • 为此,每个进程在进入临界区之前,应先对欲访问的临界资源进行检查,看它是否正被访问
  • 如果此刻临界资源未被访问,进程便可进入临界区对该资源进行访问,并设置它正被访问的标志;
  • 如果此刻该临界资源正被某进程访问,则本进程不能进入临界区
进入区(entry section)
  • 因此,必须在临界区前面增加一段用于进行上述检查的代码,把这段代码称为进入区(entry section)
退出区(exit section)
  • 相应地,在临界区后面也要 加上一段称为退出区(exit section) 的代码,用于将临界区正被访问的标志恢复为未被访问的标志。
剩余区
  • 进程中除上述进入区、临界区及退出区之外的其它部分的代码在这里都称为剩余区

同步与互斥

  • 同步有明确的先后要求
  • 互斥不要求谁先谁后,只要求一次只有一个进程访问同一个临界资源
同步

同步亦称直接制约关系,

它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而等待、传递信息所产生的制约关系。

进程间的直接制约关系就是源于它们之间的相互合作

例如,输入进程A通过单缓冲向进程B提供数据。当该缓冲区空时,进程B不能获得所需数据而阻塞,一旦进程A将数据送入缓冲区,进程B被唤醒。

反之,当缓冲区满时,进程A被阻塞,仅当进程B取走缓冲数据时,才唤醒进程A。

互斥

互斥亦称间接制约关系。(多个进程通过临界资源的使用联系起来的间接制约关系)

当一个进程进入临界区使用临界资源时,另一个进程必须等待, 当占用临界资源的进程退出临界区后,另一进程才允许去访问此临界资源。

例如,在仅有一台打印机的系统中,有两个进程A和进程B,如果进程A需要打印时, 系统已将打印机分配给进程B,则进程A必须阻塞。一旦进程B将打印机释放,系统便将进程A唤醒,并将其由阻塞状态变为就绪状态。

硬件同步机制

虽然可以利用软件方法解决诸进程互斥进入临界区的问题,但有一定难度,并且存在很大的局限性,因而现在已很少采用。

  • 相应地,目前许多计算机已提供了一些特殊的硬件指令,允许对一个字中的内容进行检测和修正,或者是对两个字的内容进行交换等。
  • 可利用这些特殊的指令来解决临界区问题。
    • 实际上,在对临界区进行管理时,可以将标志看做一个锁,“锁开”进入,“锁关”等待,初始时锁是打开的。每个要进入临界区的进程必须先对锁进行测试,当锁未开时,则必须等待,直至锁被打开。
    • 反之,(当锁是打开的时候,(则应立即把其锁上,以阻止其它进程进入临界区。
    • 显然,为防止多个进程同时测试到锁为打开的情况,测试和关锁操作必须是连续的,不允许分开进行
  • 硬件指令能有效地实现进程互斥
  • 但当临界资源忙碌时,其它访问进程必须不断地进行测试,处于一种“忙等”状态,不符合"让权等待”的原则,造成处理机时间的浪费,同时也很难将它们用于解决复杂的进程同步问题
关中断
  • 关中断是实现互斥的最简单的方法之一。
  • 在进入锁测试之前关闭中断,直到完成锁测试并上锁之后才能打开中断。
  • 这样,进程在临界区执行期间,计算机系统不响应中断,从而不会引发调度,也就不会发生进程或线程切换。由此,保证了对锁的测试和关锁操作的(连续性和完整性,有效地保证了互斥。
  • 关中断的方法存在许多缺点:
    • 滥用关中断权力可能导致严重后果;
    • ②关中断时间过长,会影响系统效率,限制了处理器交叉执行程序的能力;
    • ③关中断方法也不适用于多CPU系统,因为在一个处理器上关中断并不能防止进程在其它处理器上执行相同的临界段代码。
TestAndSet

利用Test-and-Set指令实现互斥

  • 这是一种借助一条硬件指令——“测试并建立”指令TS(Test-and-Set)以实现互斥的方法。
  • 在许多计算机中都提供了这种指令。
    • TS指令的一般性描述如下:
boolean TS(boolean *lock){
Boolean old;
old = *lock;*
lock = TRUE;
return old;
}
swap指令
void swap(boolean *a, boolean *b){
boolean temp;
temp = *a;
*a =*b;
*b = temp;
}

对换指令,交换两个字的内容

do {
//进入区
key=TRUE;//本进程表示加锁对临界资源的使用意愿以及加锁意愿
do {
swap(&lock,&key);//将当前的临界资源的状态(是否已经被锁住/还是处于空闲状态)值以交换的形式赋值给key(局部变量),同时尝试对临界资源加锁(无论此时的临界资源是否空闲(true/false))
//结果就是,如果swap的时候,lock为false(空闲),那么这个状态值被交换给key(当前进程就知道自己夺得临界资源),交换的同时,也为临界资源上了锁
//另一种情况是,临界资源本来就处于被锁(被占用的状态:true);经过交换后,测试key可知,该资源以被其他进程占用;交换同样会给资源上锁,虽然早已被其他进程加过锁了,资源状态不变(仍处于忙(被占用))
}while (key!=FALSE);//如果临界资源处在被占用(被锁住的状态),那么不停的重新试探,直到通过swap换取的值是false(资源空闲,可以离开进入区(试探循环),开始执行自己的临界区代码块)
//临界区
临界区操作(代码块);
//退出区
lock = FALSE;
}while (TRUE);

信号量机制

利用信号量实现进程互斥

  • 为使多个进程能互斥地访问某临界资源

    • 只需为该资源设置一互斥信号量mutx
    • 并设其初始值为1
    • 然后将各进程访问该资源的临界区CS置于wait(mutex)和signal(mutex)操作之间即可。
  • 这样,每个欲访问该临界资源的进程在进入临界区之前,都要先对mutex执行wait操作,

  • 若该资源此刻未被访问,本次wait操作必然成功,进程使可进入自己的临界区

    • 这时若再有其它进程也欲进入自己的临界区,由于对mutex执行wait操作定会失败,因而此时该进程阻塞,从而保证了该临界资源能被互斥地访问。
    • 当访问临界资源的进程退出临界区后,又应对mutex执行signal操作,以便释放该临界资源

利用信号量实现两个进程互斥的描述如下:

  • (1)设mutex为互斥信号量,其初值为1,取值范围为(-l,0,1)。

    • 当mutex=1时,表示两个进程皆未进入需要互斥的临界区
    • 当mutex=0时,表示有一个进程进入临界区运行
      • 另一个进程没有急着去请求进入临界区,这时将保持mutex=0
      • 如果另一进程也想请求进入临界区则必须等待,挂入阻塞队列;mutex再次减1,(mutex=-1)
    • 当mutex=-1时,表示有一个进程正在临界区运行另外一个进程同时也向进入临界区,但被拒绝而阻塞在信号量队列中需要被当前己在临界区运行的进程退出时唤醒。
  • 在利用信号量机制实现进程互斥时应该注意,wait(mutex)和signal(mutex)必须成对地出现

    • 缺少wait(mutex)将会导致系统混乱,不能保证对临界资源的互斥访问

    • 而缺少signaKmutex)将会使临界资源永远不被释放,从而使因等待该资源而阻塞的进程不能被唤醒

  • 然而,在使用信号量解决同步问题,成对可以是,signal和wait可以出现在不同的进程中;

    • 此外,signal可以连续出现,譬如下方的利用信号量实现前驱关系的分析
  • 而在利用信号量解决同步互斥综合问题的时候,上述特点关乎争取的wait(s)顺序

    • 同步信号量的P操作(wait(s)操作)在前
    • 互斥信号量的P操作在后(创造临界区代码空间)
      • 临界区的代码紧紧地被互斥信号量所包裹(wiat(mutex)/signal(mutex))
利用信号量实现前趋关系
  • 还可利用信号量来描述程序或语句之间的前趋关系。设有两个并发执行的进程P1和P2。
  • P1中有语句S1;P2中有语句S2。
  • 我们希望在S1执行后再执行S2。
  • 为实现这种前趋关系,只需使进程P1和P2共享一个公用信号量S,并赋予其初值为0,
    • 将signal(S)操作放在语句S1后面,
    • 而在S2语句前面插入wait(S)操作,即
      • 在进程P1中,用S1; signal(S);
      • 在进程P2中,用wait(S); S2;
  • 由于S被初始化为0,这样,若P2先执行必定阻塞,只有在进程P1执行完S1: signal(S);操作后使S增为1时,P2进程方能成功执行语句S2。

同样,我们可以利用信号量按照语句间的前趋关系写出一个更为复杂的可并发执行的程序。

  • image-20220531155500207

  • p1(){ S1; signal(a); signal(b);}
    p2() { wait(a); S2; signal(c); signal(d);}
    p3() { wait(b); S3; signal(e);}
    p4() { wait(c); Sa; signal(f);}
    p5() { wait(d); Ss; signal(g);}
    p6() { wait(e); wait(f); wait(g); S6;}
    main() {
    semaphore a, b, c, d, e, f, g;
    a.value=b.value=c.value=o;
    d.value=e.value=O;
    f.value=g.value=O;
    cobegin
    p1(; p2(); p3(); p4(); p5(); p6();
    coend
    }

生产者消费者问题

The proceducer-consumer problem)描述

  • 生产者-消费者(producer-consumer)问题是一个著名的进程同步问题。

  • 它描述的是:

    • 一群生产者进程(多个生产者间也可以产生制约关系)在生产产品,并将这些产品提供给消费者进程去消费。
    • 为使生产者进程与消费者进程能并发执行,在两者之间设置了一个具有n个缓冲区的缓冲池,生产者进程将其所生产的产品放入一个缓冲区中;
    • 消费者进程可从一个缓冲区中取走产品去消费。
  • 尽管所有的生产者进程和消费者进程都是以异步方式运行的,但它们之间必须保持同步,既不允许消费者进程到一个空缓冲区去取产品,也不允许生产者进程向一个已装满产品且尚未被取走的缓冲区中投放产品。

  • 我们可利用一个数组buffer来表示上述的具有n个缓冲区的缓冲池。

  • 投入(或取出)一个产品时,缓冲池buffer中暂存产品(或已取走产品的空闲单元)的数组单元指针in(或out)加1

    • 且,由于这里由buffer组成的缓冲池是被组织成循环缓冲的(需要做溢出(超过)缓冲区数n后的数值处理(控制在n内),使用取模可以囊括超过n的情况,
      • 故应把输入指针in+1,表示成in=(in+1)%n
      • 把输出指针out+1表示成:out=(out+1)%n

        对比输入输出指针:(out追赶in);经过取模赋值,in&out都小于n

        • 可以认为,in表是生产进度,out表示消耗进度,消耗的量不可能比生产的量大,基于此,在数值上,in>=out
        • in=out则表示缓冲池空。(消耗总量赶上了生产总量)
        • (in+1)%n=out时表示缓冲池满
          • (缓冲池满的状态也是in和out在循环对来上的指针重合)
          • 并且,生产进度(in)要比消耗进度(out)恰好多了n(size of the buffer pool,而无论指针重合位置在哪个缓冲区位)
  • 此外,还引入了一个整型变量counter,其初始值为0。

  • 每当生产者进程向缓冲池中投放(或取走)一个产品后,使counter加1(或减1)。

生产者和消费者两进程共享下面的变量:

int in=0, out=0, count=0;
item buffer[n];
  • 指针inout初始化为0,在生产者进程中使用一局部变量nextp,用于暂时存放每次刚刚生产出来的产品;
  • 而在消费者进程中,则使用一个局部变量nextc,用于存放每次要消费的产品

image-20220503123401123

目前,还未考虑进程的互斥与同步问题,因而造成了数据Counter的不定性。

  • 由于生产者-消费者问题是相互合作的进程关系的一种抽象,例如,在输入时,输入进程是生产者,计算进程是消费者;而在输出时,则计算进程是生产者,而打印进程是消费者,因此,该问题有很大的代表性及实用价值。

利用记录型信号量解决生产者消费者问题

  • 假定在生产者和消费者之间的公用缓冲池中具有n个缓冲区,这时可利用互斥信号量mutex实现诸进程对缓冲池的互斥使用

    • 利用信号量empty和full分别表示缓冲池中空缓冲区和满缓冲区的数量。
    • 又假定这些生产者和消费者相互等效,只要缓冲池未满,生产者便可将消息送入缓冲池;
    • 只要缓冲池未空,消费者便可从缓冲池中取走一个消息。

对生产者消费者问题可描述如下:

int in=0, out=0;
item buffer[n];
semaphore mutex=1, empty=n, full=0;
//mutex:通用的互斥信号量锁
//empty&full(专用信号量;同步信号量)
void proceducer() {
do {
producer an item nextp;
...
wait(empty);//问一下有没有空容器可以供生产者填充
wait(mutex);
//告诉其他进程(同样拥有夹在mutex信号量wait/signal代码对之间),我正在执行临界区代码(或者想要执行临界区代码),我还没执行完毕,你们不要来捣乱
buffer[in]=nextp;
in :=(in+1) % n;
signal(mutex);
signal(full);
}while(TRUE);
}
void consumer() {
do {
wait(full);//问以下有没有装有数据的缓冲区以供消费者使用
//如果能够执行这里,表示此刻该消费之能够请求到可用的满缓冲区
wait(mutex);//问以下又没有其他进程在执行临界区代码
//如果没有其他进程执行临界区代码,如果没有,则本进程开始执行临界区代码:其他进程之不要来捣乱
nextc= buffer[out];
out =(out+1) % n;
signal(mutex);
//告诉其他进程,如果有需要,可以进入临界区了
signal(empty);
//通过empty同步信号量,广播生产者,如果有需要,可以继续写入缓冲区(或者说,可供写入的缓冲区又出现了.)
consumer the item in nextc;
...
}while(TRUE);
}
void main() {
cobegin
proceducer();
consumer();
coend
}

在生产者-消费者问题中应注意:

  • 首先,在每个程序中用于实现互斥的wait(mutex)和signal(mutex)必须成对地出现;
  • 其次,对资源信号量empty和full的wait和signal操作,同样需要成对地出现,但它们分别处于不同的程序中。
    • 例如,wait(empty)在计算进程中,而signal(empty)则在打印进程中,计算进程若因执行wait(empty)而阻塞,则以后将由打印进程将它唤醒;
    • 最后,在每个程序中的多个wait操作顺序不能颠倒。应先执行对资源信号量
  • 的wait操作,然后再执行对互斥信号量的wait操作,否则可能引起进程死锁。

AND信号量&管程方式

AND信号量

  • 对于生产者-消费者问题,也可利用AND信号量来解决,

  • 即用Swait(empty,mutex)来代替wait(empty)和 wait(mutex);

  • 用Ssignal(mutex, full)来代替signal(mutex)和signal(full):

  • 用Swait(full,mutex)代替 wait(full)和 wait(mutex),

  • 以及用Ssignal(mutex,empty)代替Signal(mutex)和 Signal(empty)。

管程方式

管程的一般优点
  • 虽然信号量机制是一种既方便、又有效的进程同步机制,但每个要访问临界资源的进程都必须自备同步操作 wait(S)和 signal(S)。
  • 这就使大量的同步操作分散在各个进程中。
  • 这不仅给系统的管理带来了麻烦,而且还会因同步操作的使用不当而导致系统死锁
  • 这样,在解决上述问题的过程中,便产生了一种新的进程同步工具——管程(Monitors).

管程解决生产者-消费者问题

  • 在利用管程方法来解决生产者-消费者问题时,首先便是为它们建立一个管程,并命名为procducerconsumer,或简称为PC。其中包括两个过程:

    • (1) put(x)过程。生产者利用该过程将自己生产的产品投放到缓冲池中,并用整型变量count来表示在缓冲池中已有的产品数目,当count≥N时,表示缓冲池已满,生产者须等待。
    • (2) get(x)过程。消费者利用该过程从缓冲池中取出一个产品,当count≤0时,表示缓冲池中已无可取用的产品,消费者应等待。
  • 对于条件变量notfullnotempty,分别有两个过程cwaitcsignal对它们进行操作:

    • (1) cwait(condition)过程:当管程被一个进程占用时,其他进程调用该过程时阻塞,并挂在条件condition的队列上。
    • (2) csignal(condition)过程**:唤醒**在cwait执行后阻塞在条件condition队列上的进程,如果这样的进程不止一个,则选择其中一个实施唤醒操作;如果队列为空,则无操作而返回。
对应的管程定义
  • 管程可以很自然的实现进程互斥
  • 信号量可以很自然的实现进程同步(signal(s)/wait(s))先后执行关系
  • 通过设置同步工具,管程也可以实现进程同步.
  • 系统中的各种硬件资源和软件资源均可用数据结构抽象地描述其资源特性

    • 即用少量信息对该资源所执行的操作表征该资源,而忽略它们的内部结构和实现细节。
  • 因此,可以利用共享数据结构抽象地表示系统中的共享资源,并且将对该共享数据结构实施的特定操作定义为一组过程

    • 进程对共享资源的申请、释放和其它操作必须通过这组过程(进程)间接地对共享数据结构实现操作
    • 对于请求访问共享资源诸多并发*进程,可以根据资源的情况接受或阻塞*,确保每次仅有一个进程进入管程执行这组过程,使用共享资源,达到对共享资源所有访问的统一管理,有效地实现进程互斥
  • 代表共享资源的数据结构以及由(对该共享数据结构实施操作的)一组过程所组成的资源管理程序 共同构成了一个操作系统的资源管理模块,我们称之为管程

  • 管程被请求和释放资源的进程所调用

  • Hansan为管程所下的定义是:“一个管程定义了一个数据结构能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据。”

  • 由上述的定义可知,管程由四部分组成:

    • ①管程的名称;
    • 局部于管程的共享数据结构说明;
    • ③对该数据结构进行操作的一组过程:
    • ④对局部于管程的共享数据设置初始值的语句
  • 实际上,管程中包含了面向对象的思想,它将表征共享资源的数据结构及其对数据结构操作的一组过程,包括同步机制,都集中并封装在一个对象内部,隐藏了实现细节。

    • 封装于管程内部的数据结构仅能被封装于管程内部的过程所访问,任何管程外的过程都不能访问它;

    • 反之,封装于管程内部的过程也仅能访问管程内的数据结构

    • 所有进程要访问临界资源时,都只能通过管程间接访问,而管程每次只准许一个进程进入管程,执行管程内的过程,从而实现了进程互斥

    • 管程是一种程序设计语言的结构成分,它和信号量有同等的表达能力,从语言的角度看,管程主要有以下特性:

      • ①模块化,即管程是一个基本程序单位,可以单独编译;
      • ②抽象数据类型,指管程中不仅有数据,而且有对数据的操作;
      • ③信息掩蔽,指管程中的数据结构只能被管程中的过程访问,这些过程也是在管程内部定义的,供管程外的进程调用,而管程中的数据结构以及过程(函数)的具体实现外部不可见
      • 管程和进程不同:
        • ①虽然二者都定义了数据结构,但进程定义的是私有数据结构PCB,管程定义的是公共数据结构,如消息队列等
        • ②二者都存在对各自数据结构上的操作,但进程是由顺序程序执行有关操作,而管程主要是进行同步操作和初始化操作
        • ③设置进程的目的在于实现系统的并发性,而管程的设置则是解决共享资源的互斥使用问题
        • ④进程通过调用管程中的过程对共享数据结构实行操作,该过程就如通常的子程序一样被调用,因而管程为被动工作方式,进程则为主动工作方式
        • ⑤进程之间能并发执行,而管程则不能与其调用者并发
        • ⑥进程具有动态性,由“创建”而诞生,由"撤消”而消亡,而管程则是操作系统中的一个资源管理模块,供进程调用
          • 条件变量在利用管程实现进程同步时,必须设置同步工具,如两个同步操作原语wait和signal
          • 当某进程通过管程请求获得临界资源而未能满足时,管程便调用wait原语使该进程等待,并将其排在等待队列上
          • 如图2-13所示。
          • 仅当另一进程访问完成并释放该资源之后,管程才又调用signal原语,唤醒等待队列中的队首进程。
          • 但是仅仅有上述的同步工具是不够的,
          • 考虑一种情况:当一个进程调用了管程,在管程中时被阻塞或挂起,直到导致阻塞或挂起的原因解除,而在此期间,如果该进程不释放管程,则其它进程无法进入管程被迫长时间的等待(不满足空闲让进)。
          • 为了解决这个问题,引入了条件变量conditiono通常,
            • 一个进程被阻塞或挂起的条件(原因)可有多个,因此在管程中设置了多个条件变量,对这些条件变量的访问只能在管程中进行。
            • 管程中对每个条件变量都须予以说明,其形式为:conditionx,y;
            • 对条件变量的操作仅仅是wait和signal,因此条件变量也是一种抽象数据类型,每个条件变量保存了一个链表,用于记录因该条件变量而阻塞的所有进程,同时提供的两个操作即可表示为x.wait和x.signal.其含义为:
              • ①x.wait:正在调用管程的进程因x条件需要被阻塞或挂起,则调用x.wait将自己插入到x条件的等待队列上,并释放管程,直到x条件变化。此时其它进程可以使用该管程。
              • ②x.signal:正在调用管程的进程发现x条件发生了变化调用x.signal,重新启动一个因x条件而阻塞或挂起的进程,如果存在多个这样的进程,则选择其中的一个,如果没有,继续执行原进程,而不产生任何结果。
                • 这与信号量机制中的signal操作不同
                • 因为,后者总是要执行s:=s+l操作,因而总会改变信号量的状态
                • 如果有进程Q因x条件处于阻塞状态,当正在调用管程的进程P执行了x.signal操作后,进程Q被重新启动,此时两个进程P和Q,如何确定哪个执行哪个等待,可采用下述两种方式之一进行处理:
                  • (1)P等待,直至Q离开管程或等待另一条件。
                  • (2)Q等待,直至P离开管程或等待另一条件。
                  • 而Hansan选择了两者的折中,他规定管程中的过程所执行的signal操作是过程体的最后一个操作,于是,正在调用管程的进程P执行signal操作后立即退出管程因而,进程Q马上被恢复执行
    • PC管程可描述如下:
      Monitor procducerconsumer
      {
      item buffer[N];
      int in, out;
      condition notfull, notempty;
      int count;
      public:
      void put(item x) {
      if(count>=N) cwait(notfull);
      buffer[in]= x;
      in = (in+1) % N;
      count++;
      csignal(notempty);
      }
      void get(item x){
      if (count<=0) cwait(notempty);
      x = buffer[out];
      out = (out+1) % N;
      count--;
      csignal(notfull);
      }
      {in=0;out=O;count=O; }
      }
      PC;
管程对应的生产者和消费者定义
  • //生产者进程通过调用管程中的put()函数,将传入的参数作为生产资料产生内容(数据)(被通过管程写入到共享变量(缓冲区数组中))
    void producer() {
    item x;
    while(TRUE){
    ...
    produce an item in nextp;
    PC.put(x);
    }
    }
    //消费者进程通过调用管程中的get()函数(间接)实现消费
    void consumer) {
    item x;
    while(TRUE){
    PC.get(x);
    consume the item in nextc;
    ....
    }
    }
    void main() {
    cobegin
    producer();
    consumer();
    coend
    }
posted @   xuchaoxin1375  阅读(20)  评论(0编辑  收藏  举报  
相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
历史上的今天:
2021-05-03 Android studio_像IDEA的代码分析结果(problmes栏)描述和错误定位/优化定位(替代方案)
点击右上角即可分享
微信分享提示