多线程与线程互斥
我们初步学习完线程之后,就要来试着写一写多线程了。在写之前,我们需要继续来学习一个线程接口——叫做线程分离。
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join
操作,否则无法释放资源,从而造成系统泄漏。如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
功能:用于分离一个线程,需要头文件<pthread.h>
。
函数原型:int pthread_detach(pthread_t thread);
参数:thread
是被线程分离的ID。
返回值:分离成功返回0,分离失败返回错误码。
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:pthread_detach(pthread_self());
注意:一个线程被分离后,不允许再被线程等待,这样会发生报错。
void *thread_run(void *arg)
{pthread_detach(pthread_self());printf("%s\n", (char *)arg);return NULL;
}int main()
{pthread_t tid;if (pthread_create(&tid, NULL, thread_run, (void *)"thread run...") != 0){printf("create thread error\n");return 1;}int ret = 0;sleep(1); // 很重要,要让线程先分离,再等待.目的是确保新创建的线程有足够的时间执行if (pthread_join(tid, NULL) == 0){printf("pthread wait success\n");ret = 0;}else{printf("pthread wait failed\n");ret = 1;}cout << ret << endl;return 0;
}
由于线程被分离,所以pthread_join
实际上应该是失败的,除非在等待之前线程已经结束。当把pthread_detach(pthread_self());
注释掉后,就会显示等待成功。
线程互斥
线程一旦被创建,线程几乎都会共享同一份资源。那么这样会不会出现一个问题,就是一个线程正在做事情,还没有做完,这时其他线程就有可能也会开始干这件事,导致程序出错。
我们来模拟一下抢票过程,演示并讲解 什么是线程互斥。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <thread>
#include <vector>
#include <cstdio>
using namespace std;// 线程互斥
#define NUM 4 // 4个线程
class threadData
{
public:threadData(int number){threadname = "thread-" + to_string(number);}
public:string threadname;
};int tickets = 1000; // 用多线程,模拟一轮抢票,票数设置为1000张
void *getTickets(void *args)
{threadData *td = static_cast<threadData *>(args);const char *name = td->threadname.c_str();while (1){if (tickets > 0){usleep(1000);printf("who = %s, get a ticket:%d\n", name, tickets);tickets--;}elsebreak;}printf("%s quit...\n", name);return nullptr;
}int main()
{vector<pthread_t> tids;vector<threadData *> thread_datas;for (int i = 1; i <= NUM; i++){pthread_t tid;threadData *td = new threadData(i);thread_datas.push_back(td);pthread_create(&tid, nullptr, getTickets, thread_datas[i - 1]);tids.push_back(tid);}for (auto thread : tids){pthread_join(thread, nullptr);}for (auto td : thread_datas){delete td;}return 0;
}
我们模拟的是一个抢票系统,创建4个线程加1个主线程来抢1000张票,当票数小于等于0时就退出抢票。
我们首先要模拟一个顾客,可以用线程来模拟,封装成一个类。
class threadData
{
public:threadData(int number){threadname = "thread-" + to_string(number);}
public:string threadname;
};
其中的变量threadname
代表的是哪个线程,即顾客的名字。
接着我们让线程去执行抢票程序。
void *getTickets(void *args)
{threadData *td = static_cast<threadData *>(args);const char *name = td->threadname.c_str();while (1){if (tickets > 0){usleep(1000);printf("who = %s, get a ticket:%d\n", name, tickets);tickets--;}elsebreak;}printf("%s quit...\n", name);return nullptr;
}
抢票程序会一直执行下去,直到票数小于等于0,tickets
代表的是票的编号,哪个线程抢到了哪张票。
主函数中,
int main()
{vector<pthread_t> tids;vector<threadData *> thread_datas;for (int i = 1; i <= NUM; i++){pthread_t tid;threadData *td = new threadData(i);thread_datas.push_back(td);pthread_create(&tid, nullptr, getTickets, thread_datas[i - 1]);tids.push_back(tid);}for (auto thread : tids){pthread_join(thread, nullptr);}for (auto td : thread_datas){delete td;}return 0;
}
先声明了一个存储线程 ID 的vector
容器,用于保存创建的多个线程的标识符,接着声明了一个存储指向threadData
类型指针的vector
容器,用于保存每个线程所需要的数据结构的指针。然后创建4个线程,每个线程都去执行抢票函数,并且把每个线程的tid都存储在tids
数组当中。第二个for
循环等待5个线程,最后一个for
循环是释放每个动态分配的threadData
对象的内存。
我们来看一下结果:
奇怪的是,我们只有1000张票,为什么会抢到1003张票呢?最后的0,-1,-2是怎么回事。
接下来,我们就来深入解析一下。根据代码结果,我们会知道一个问题就是共享数据会造成数据不一致问题,这肯定和多线程并发访问是有关的,对一个全局变量进行多线程并发进行–/++操作是不安全的。我们也知道要对数据做计算,必须是在CPU内部进行的,因此进行tickets--
操作是分3步的。
而且每一步都会对应一条汇编指令,比如以下这样:
MOV eax, [0x1000] ; 读取 tickets 的值到CPU的寄存器中
DEC eax ; 减 1
MOV [0x1000], eax ; 将值写回 tickets并保存到内存当中
在解释之前,我们要先知道一个知识点:任何一个线程在任意一个时间点都有可能会被切换。先假设线程1开始执行抢票函数。首先开始执行tickets--
的第一步操作:读取数据,然后突然线程1的时间片结束了,要结束调度当前进程,但并不是把自己的PCB拿走就完了,它要把自己的上下文数据保存起来。为什么会有上下文数据呢?因为线程在执行调度执行的时候,每个线程都是CPU调度的基本单位,所以当它被调度的时候,它一定会有自己的硬件上下文。
线程在执行的时候,将共享数据加载到CPU寄存器的本质:把数据的内容,变成了自己的上下文,即以拷贝的方式,给自己单独拿了一份。
线程1被切换走之后,线程2开始执行抢票过程,不过线程2的运气很好,把3个过程全部执行完了。tickets
变成了999。然后线程2一直执行下去,直到tickets
变成了10。
变成10之后,线程2的第一步操作:读取数据完成,接着正准备执行第二步操作:--
的时候,它的时间片结束了,于是线程2被切换的时候把10保存到了自己的上下文中。
这时又该到了线程1执行的时候,不过线程1开始执行的时候,并不是执行上一次未完成的第二步操作,而是先恢复自己的上下文,即把1000保存到CPU的寄存器当中,完成之后,执行剩下的步骤。
OK,这样我们就知道了多线程抢票的基本过程了,现在我们来了解一下为什么会出现票数为负数的情况。我们假设经过多线程抢票之后,票数只剩下了一张,当进行抢票的时候,首先要判断tickets
是否大于0,判断语句也是一种运算,而判断的本质逻辑是先读取内存数据到CPU的寄存器当中,然后再进行判断;打印票数的时候,也需要读取数据。
所以tickets=1
的时候,多个线程可以同时执行这个判断语句吗?这当然是不行的,我们只有一个CPU,也只有一个寄存器,也代表CPU每次只能执行一次运算,不能同时判断,但是注意我们写了usleep
语句,线程是要被切换走的,寄存器中的内容是属于这个线程上下文的。
线程1被切换走的时候,剩下的线程就进入循环开始竞争执行if语句的判断,但都会先被usleep
休眠,当剩余线程休眠完之后,线程1开始被唤醒,先恢复自己的上下文,此时线程1从内存读取到票数是1,然后执行tickets--
的三步,步骤执行完成之后写回内存当中的tickets
变成了0。
紧接着线程2被唤醒,跟线程1的步骤一样,先恢复上下文,它不知道内存当中的tickets
变成了0,往下执行–操作,此时读取到的tickets
是0,但是已经在if语句的内部当中了,只能往下执行代码,所以写回内存中的票数就变成了-1。线程2和线程3也是一样,执行–操作之后,从内存读取到的数据是-1,写回内存当中就变成了-2。
就是因为我们把判断和更新分开,而在中间发生了大量的线程切换,最终可能出现tickets
本来就是1,但是确让大量的线程同时对一个变量进行操作,从而导致出现了票数为负的情况。
锁
那么这种情况该如何解决呢?简单来说就是对共享数据的任何访问,必须保证任何时候只有一个执行流进行访问,于是就诞生了“锁”的概念。
在讲解这个概念之前,我们要先知道几个概念:
临界资源
:在某段时间内只允许一个进程使用的资源称为临界资源。临界区
:每个进程中访问临界资源的那段程序称为临界区。原子性
:表示一个操作对外表现只有两种状态:还没开始
和已经结束
。不可能出现第三种状态,正在执行中
。
几个进程共享同一临界资源,它们必须以相互排斥的方式使用临界资源,即当一个进程正在使用临界资源且尚未使用完毕时,其它进程必须延迟对该资源的进一步操作,在当前进程使用完毕之前,不能从中插入使用这个临界资源,否则将会造成信息混乱和操作出错。
用刚才的抢票过程来帮助我们理解原子性这个概念。我们说过tickets--
是对应三条汇编指令的,并不是在一瞬间就把tickets--
这个操作完成的,所以这就会导致一个线程在执行3条指令的过程中,就有可能会有其他的线程将其打断。假如说一个线程在抢完票之后,tickets--
操作立即执行完成,下一个线程在访问这个变量的时候,一定是上一个线程把三条指令全部执行完毕,而不是在tickets--
的过程中,就可以避免这个多线程并行访问问题的发生。所以这就要求tickets
是原子的,即要么没有线程执行tickets--
操作,要么已经执行完毕,根本不可能是在tickets--
的过程中。
那么该如何保证tickets
是原子的呢?这就该要学习线程互斥了,什么是线程互斥呢?
线程互斥通过对共享资源的访问进行限制,确保在同一时刻只有一个线程能够访问该资源。这样可以避免多个线程同时对共享资源进行修改而产生不可预测的结果。
线程互斥可以有效地保护共享资源,确保多线程程序的正确性和稳定性。但同时也可能会带来一定的性能开销,因为线程在获取和释放互斥锁时需要进行一些系统调用和同步操作。
线程互斥是靠“锁”实现的,Linux上提供的这把锁叫互斥锁,锁的规则如下:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
互斥锁
互斥锁是第三方线程库pthread
库提供的,需要头文件<pthread.h>
,我们先讲解锁的创建和销毁。互斥锁的类型是pthread_mutex_t
,可以分为全局互斥锁和局部互斥锁,这两个锁的创建方式也不同,我们一会会把2种创建方式都展示一下。
全局互斥锁
函数原型:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
这样就创建了一个全局变量的互斥锁,这种方式也叫静态分配,全局的互斥锁必须使用宏来初始化,即用PTHREAD_MUTEX_INITIALIZER
来初始化。定义在全局的互斥锁,可以不需要手动去释放锁,不过你想手动去释放的话,也是可以的,不过不建议这样做。
局部互斥锁
pthread_mutex_init
函数原型:int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);
功能:是用来初始化锁的。
参数:mutex
,类型是pthread_mutex_t *
的指针,指向一个互斥锁变量,第二个参数是锁的属性,一般不需要考虑这个,设置为nullptr
即可。
返回值:成功返回0,失败返回错误码。
pthread_mutex_destory
函数原型:int pthread_mutex_destroy(pthread_mutex_t *mutex);
功能:销毁互斥锁。
参数:跟初始化的时候一样,需要销毁的时候,传入变量即可。
返回值:成功返回0,失败返回错误码。
销毁互斥锁的时候需要注意三点:
- 使用
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁。 - 不要销毁一个已经加锁的互斥量。
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
互斥锁被创建好之后,需要对锁进程操作,主要有2个操作:申请锁和释放锁。
函数原型如下:
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread_mutex_lock:pthread_mutex_lock
锁定给定的互斥锁。如果互斥锁当前处于解锁状态,则它将被锁定并归调用线程所有,并且pthread_mutex_lock
立即返回。如果互斥锁已被另一个线程锁定,则pthread_mutex_lock
会暂停调用线程,进行阻塞等待,直到互斥锁已解锁。
pthread_mutex_unlock:解锁互斥锁,表明已经访问完毕临界区了,其他线程可以来访问了。
现在我们来演示一下局部创建一个互斥锁,还是刚才的抢票过程。
首先我们要知道,必须要让每个线程使用同一把锁才不会出现之前的问题,即就是每个线程进入临界区访问临界资源的时候都需要访问同一把锁。加锁的原则是尽量的要保证临界区代码,越少越好。加锁和解锁之间的区域叫做临界区,所以pthread_mutex_lock
接口要放在抢票函数的while循环当中。
我们可以把加锁接口放在while循环外面吗?从代码上来说是可以的,因为加锁的位置是程序员决定的,想加在哪都可以,但是逻辑上来说,是不对的,因为加在外部的话,第一个线程就会一直进行抢票,直到为0,这样就会导致其他线程没有票可以抢。既然加锁的位置需要考虑,那么解锁的位置也需要考虑,如果按照图片中解锁位置放置的话,就会导致线程进入临界区之后,要是没有票就会直接break掉,这样下一个线程就无法进入到临界区当中了。所以正确的位置如下所示。
class threadData
{
public:threadData(int number, pthread_mutex_t *mutex){threadname = "thread-" + to_string(number);lock = mutex;}
public:string threadname;pthread_mutex_t *lock;
};int tickets = 1000; // 用多线程,模拟一轮抢票,票数设置为1000张void *getTickets(void *args)
{threadData *td = static_cast<threadData *>(args);const char *name = td->threadname.c_str();while (1){pthread_mutex_lock(td->lock); // 申请锁成功。才能往后执行。不成功就阻塞等待if (tickets > 0){usleep(1000);printf("who = %s, get a ticket:%d\n", name, tickets);tickets--;pthread_mutex_unlock(td->lock);}else{pthread_mutex_unlock(td->lock);break;}}printf("%s quit...\n", name);return nullptr;
}
int main()
{pthread_mutex_t lock;pthread_mutex_init(&lock, nullptr); // 初始化锁vector<pthread_t> tids;vector<threadData *> thread_datas;for (int i = 1; i <= NUM; i++){pthread_t tid;threadData *td = new threadData(i, &lock);thread_datas.push_back(td);pthread_create(&tid, nullptr, getTickets, thread_datas[i - 1]);tids.push_back(tid);}for (auto thread : tids){pthread_join(thread, nullptr);}for (auto td : thread_datas){delete td;}pthread_mutex_destroy(&lock); // 销毁锁return 0;
}
代码结果:
从结果看出,没有出现抢到负数的情况了,但是我们发现只有一个线程在一直抢票。这种情况正常吗?其实是挺正常的,因为线程对于锁的竞争能力可能会不同。这个现象我们用一个例子进行讲解。
比如说你们学校里有一个VIP自习室,这个自习室一次只能进去一个人上自习,当有人在里面自习的时候,外面的人就只能等待,而且这个自习室外面放置了一把钥匙,当上自习的时候,就用这个钥匙把门打开,进去在把门锁上,自习完毕的时候,就把钥匙放回原位,让下一个人自习。
你每天都是早早的起床,然后第一个到达自习室并且上自习,有一天你学了2个小时之后,觉得肚子饿了,然后收,拾东西就去吃早饭,当你走到门口之后,看到门外站了许多人都在等待自习室的使用权,你觉得要是自己走了之后,在回来还想上自习,那么就需要等待前面的人用完之后才能轮到你。于是没有走,而是又去学了十几分钟,觉得饿了又要准备出去,然后你就开门——放钥匙——拿钥匙——关门——……。因为这个钥匙离你最近,其他人需要往前走几步才能拿到钥匙,所以你就重复上面的步骤,导致其他人只能等待,因为长时间得不到锁资源导致产生了饥饿问题,我们把这种环境叫做“纯互斥”环境。
这个案例就能说明线程对于锁的竞争能力会有不同,第一个线程会抢完票之后,又会接着下一次的抢票。那么这种情况该如何解决呢?这个时候自习室有一个观察员,它制定了几条规则:一、外面来的,必须排队;二、出来的人,不能立即重新申请锁,必须排到队列的尾部。根据这几条规则,就让所有的线程(人)获取锁(钥匙),按照一定的顺序性获取资源,把这种现象叫做同步。
每个线程必须申请同一把锁,才能进入到临界区,所以锁也是共享资源。线程在申请锁的时候,锁也是保护共享资源的,那么谁来保证锁的安全呢?其实这点完全不用担心,申请锁和释放锁本身就被设计成为了原子性操作。在临界区中,线程是可以被切换的,在线程被切出去的时候,是持有锁被切走的,线程不在的期间,照样没有人能进入临界区访问临界资源。这就相当于你在自习室的时候,想去上厕所,但是你并不想把钥匙放在原位,于是你就带在身上,这样其他人就无法进入到这个自习室了。
这次我们把锁设置成全局变量。
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
class threadData
{
public:threadData(int number){threadname = "thread-" + to_string(number);}
public:string threadname;
};
void *getTickets(void *args)
{threadData *td = static_cast<threadData *>(args);const char *name = td->threadname.c_str();while (1){pthread_mutex_lock(&lock);if (tickets > 0){usleep(1000);printf("who = %s, get a ticket:%d\n", name, tickets);tickets--;pthread_mutex_unlock(&lock);}else{pthread_mutex_unlock(&lock);break;}usleep(13); // 我们抢到了票,我们会立马抢下一张吗?其实多线程还要执行得到票之后的后续动作。使用usleep模拟}printf("%s quit...\n", name);return nullptr;
}
注意看,我在抢票函数中的末尾加入了usleep(13)
,目的是为了更好的模拟出真实情况,因为抢完票之后还会有其他的地址,并不会立即抢下一张票。(该代码结果与锁是局部还是全局的无关,与最后的usleep
有关)
互斥锁原理
那么互斥锁是如何做到可以把临界区变成原子性的呢?为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
先来看一下加锁的汇编(伪代码)
lock:movb $0, %alxchgb %al, mutexif(al寄存器的内容 > 0){return 0;}else挂起等待;go to lock;
我们通过图片来讲解一下原理:
看到有2个线程,一个CPU和一个内存,CPU内部有一个寄存器,用来存储变量的值,而内存中锁的值,我们假设是1。
现在假设线程1来执行pthread_mutex_lock
指令,首先执行movb $0, %al
,意思是把寄存器中的值设置为0。
然后执行xchgb %al, mutex
,意思是把mutex
的值与寄存器中的值进行交换。这样寄存器中的值就变成了1,mutex
的值就变成了0.
接着执行剩下的代码,
if(al寄存器的内容 > 0){return 0;
}else挂起等待;
所以说就是判断寄存器中的值是0还是大于0的情况,如果大于0就证明争夺到了锁,lock
函数就返回0,否则就进行挂起等待。这样一个线程就得到了一把锁。这是基本情形,下面我们来看一下特殊的情况。
当线程1在执行完第一条汇编指令的时候,寄存器中的值是1,然后线程2来调度了。
别忘了线程在任何时候都有可能被切换。线程1在被切换的时候,保存了自己的上下文,包括寄存器中的值,然后线程2来调度,执行了2条汇编指令,把内存中的mutex
的值与寄存器中的值进行交换,于是线程2也申请到了锁。
此时线程1再次被调度,线程2被切换走了,保存自己的上下文,线程1重新开始执行汇编。
线程1恢复上下文之后,线程1的&al
为0,然后与mutex
的值进行交换,但是mutex
的值也是0,这表明锁已经被另一个线程拿走了,于是执行if语句的内容,线程1就只能挂起等待了。
所以并不是谁先调用lock
函数,谁就能抢到锁,而是谁先执行xchgb %al, mutex
语句,把非0值放到自己的%al
中,谁才能抢到锁。
因此交换的本质就是把内存中的数据,交换到CPU的寄存器当中,即就是把数据交换到线程的硬件上下文中。把一个共享的锁,让一个线程以一条汇编的方式,交换到自己的上下文中。
再来看解锁汇编:
unlock:moveb $1, mutex唤醒等待mutex的线程;return 0;
其实就是把寄存器中的1交还给mutex
,接着唤醒所有等待mutex
的线程,让它们重新开始竞争锁,最后返回0。
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。若无外力作用,这组进程或线程将永远不能执行。
下面,我将举一个例子,并且用画图的方式来帮助大家理解死锁的原理。
如图所示,有四辆车同时到达了一个十字路口,如果这四辆车中有一辆右拐让路,则可以避免堵塞的发生,但是如果四辆车各不相让,都继续执行,就会发生图示的堵塞情况——堵塞。
上述举例死锁发生的原因可知,每辆车都占据着一个车道,因为所需要的第二个车道被另一辆车占据,所以导致四辆车都不能前进。如果把车道看成是车辆行驶必须拥有的资源,由于每一辆车都拥有一个资源(车道),又试图占据另一个已被其他车辆占有的资源(车道),而系统中的资源有限(只有四个车道),因此四辆车都不能前进,导致死锁。所以资源不足是产生死锁的原因之一。
现在我把这个示例转换成计算机的视角来演示一遍。现在有2个线程和2把互斥锁。
现在有个要求,一个线程要想访问临界资源,必须同时拥有2把锁,然后线程1去申请mutex-1
,线程2去申请mutex-2
。
接着线程1去申请mutex-2
,但是这把锁已经被线程2申请了,于是线程1陷入阻塞;同理,线程2申请mutex-2
,这把锁已经被线程1申请了,线程2陷入阻塞。
现在线程1等待线程2解锁mutex-2
,线程2等待线程1解锁mutex-1
,双发互相等待,然后就永远的陷入等待状态,这就是死锁。
发生死锁的4个必要条件如下:
- 互斥条件。指线程对所分配的资源进行排他性使用,即在一段时间内某资源只能由一个线程占有。如果此时有其他线程要求使用该资源,要求使用资源者只能阻塞,直到占用该资源的线程用完该资源为止。
- 请求和保持。线程已经占有了至少一个资源,但又提出了新的资源请求,而该资源已经被其他线程占有,此时线程阻塞,但继续占有已经获得的资源。
- 不可剥夺条件。线程已经获得了资源,在它使用完毕前不能被剥夺,只能使用完毕后自己释放。
- 环路条件。存在一个线程与资源的环形链,在该链中,每个线程都正在等待一个被占用的资源。
如果产生了死锁,那么该如何解除呢?
解除死锁的方法
- 撤销所有死锁进程。这是操作系统中最常用的方法,也是最容易实现的方法。
- 把每个死锁的进程恢复到前面定义的某个检查点,并重新运行这些进程。要实现这个方法需要系统有重新运行和重新启动机制。该方法的风险是有可能再次发生原来发生过的死锁,但是操作系统的不确定性(随机性)使得不会总是发生同样的事情。
- 有选择地撤销死锁进程,直到不存在死锁。选择撤销进程的顺序基于最小代价原则。每次撤销一个进程后,要调用死锁检测算法检测是否仍然存在死锁。
- 剥夺资源,直到不存在死锁。和第三点一样,也需要基于最小代价原则选择要剥夺的资源。同样也需要在每次剥夺一个资源后调用死锁检测算法,检测系统是否仍然存在死锁。
最小代价原则
- 到目前为止消耗的处理机时间最少。
- 到目前为止产生的输出最少。
- 预计剩下的执行时间最长。
- 到目前为止分配的资源总量最少。
- 进程的优先级最低。
- 撒销某进程对其他进程的影响最小