在单核CPU中,操作系统为了营造多个进程在同时运行的假象,它会为每个进程分配时间片,如果一个进程的时间片结束,那么就会进程切换,由于时间片很短,就造成并发的现象。
如果一个进程只有一个执行流,那么它就是单线程,当然一个进程可以由多个执行了,也就是多线程程序。多个线程之间,共享进程的大部分资源,但线程也有一套自己的寄存器和独立的栈结构。
为了提升并发度,当多个线程访问临界资源的时候,会因为时序问题(时钟中断),导致数据不一致问题!
接下来就使用Linux下的原生线程库来模拟多个线程竞争共享资源,导致数据不一致的多线程抢票问题。
抢票代码1.0版本:
#define NUM 3
int ticket = 1000;
void *getTicket(void *args)
{
uint64_t number = (uint64_t)args;
while (true)
{
//可能当多个线程判断为真之后,就切换,多个线程,切换回来继续执行,ticke此时等于1,有两个线程进来,对ticket--两次,所以会出现负数。
if (ticket > 0)
{
usleep(1000); // 模拟业务
cout << "the thread" << number << " get ticket "
<< ticket << endl;
ticket--;
}
else
break;
usleep(10); //注意这里!
}
}
int main()
{
vector tids;
for (uint64_t i = 0; i < NUM; i++)
{
pthread_t tid;
pthread_create(&tid, nullptr, getTicket, (void *)i);
tids.push_back(tid);
}
for(auto tid : tids)
{
pthread_join(tid,nullptr);
}
return 0;
}
输出结果:
the thread0 get ticket 1000
.......
the thread0 get ticket 1
the thread1 get ticket 0
the thread2 get ticket -1
为什么会出现这样无法取得正确的结果
一行汇编就是原子的,但是--
操作对应的三行汇编,我们可以一些工具查看到。
这里使用 -g
选项保留了调试信息,objdump
可以显示源代码和汇编的对应关系,更容易找到对应源代码和汇编的对应关系。
[xiyan@hecs-34711 mutual]$ g++ -g -o mutual mutual.cc -std=c++11 -lpthread
[xiyan@hecs-34711 mutual]$ objdump -Sd mutual > mutual.objdump
查看对应的mutual.objdump可以找到,ticket–对应的汇编
ticket--;
400b05: 8b 05 91 15 20 00 mov 0x201591(%rip),%eax # 60209c <ticket>
400b0b: 83 e8 01 sub $0x1,%eax
400b0e: 89 05 88 15 20 00 mov %eax,0x201588(%rip) # 60209c <ticket>
如图示例ticket--
操作:
在上面的例子中,多个线程访问的票是共享资源,把多线程执行流共享的资源就叫做临界资源;而访问临界资源的代码称之为临界区。在上面的抢票代码1.0版本当中,多个线程进入临界区,修改临界资源,输出的结果是存在不确定性,这对于一个抢票系统而言是不能忍受的。
所以要对临界资源进行保护,保证任何时刻,有且只有一个执行流进入临界区,访问临界资源,这就是互斥。
注意:多线程或多进程在访问临界资源的时候,为了保护资源的安全性,都可以采用互斥的方法。
如何解决上面出现数据不确定性的问题
如果要解决这个问题:1.使用互斥的方法,当一个线程,进入临界区执行,其他的线程就不允许进入该临界区 2.临界区中要保证只有一个线程进入 3.另外,如果一个线程从临界区中离开,它就不能阻止其他线程进入临界区。
如果要满足这三点,本质上只需要加一把锁!用于管理资源竞争和保证数据一致性,Linux中提供的锁称为互斥量。下面使用互斥量来实现互斥代码。
接口说明
初始化互斥量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrictattr);
参数:
mutex:要初始化的互斥量
restrictattr:NULL
销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex)
注意:
使用静态分配PTHREAD_MUTEX_INITIALIZER
的互斥量不用手动释放
不要销毁已经加锁的互斥量,另外,已经释放的互斥量要保证不会有线程在尝试加锁
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,错误返回错误号。
在调用 pthread_ lock时:1.互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功;2.发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
售票系统2.0:
//方法一:静态分配
//pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
//方法二:动态分配
pthread_mutex_t lock;
#define NUM 3
int ticket = 1000;
void *getTicket(void *args)
{
uint64_t number = (uint64_t)(args);
while (true)
{
pthread_mutex_lock(&lock);
if (ticket > 0)
{
// 如果线程切换走根本不怕,锁携带的线程的上下文,其他线程也进不了临界区
usleep(100);
cout << "the thread" << number << " get ticket "
<< ticket << endl;
ticket--;
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
break;
}
//刚抢完一张票还会立即抢吗,这里usleep(10)模拟抢票的业务逻辑,我们没有这会怎么样?
//线程饥饿问题!
usleep(10);
}
}
int main()
{
pthread_mutex_init(&lock,nullptr);
vector<pthread_t> tids;
for (uint64_t i = 0; i < NUM; i++)
{
pthread_t tid;
pthread_create(&tid, nullptr, getTicket, (void*)i);
tids.push_back(tid);
}
for(auto tid : tids)
{
pthread_join(tid,nullptr);
}
pthread_mutex_destroy(&lock);
return 0;
}
上面的代码加锁,保证了临界区的互斥问题,虽然加锁会导致一个程度的效率降低,但是这是用时间换安全的一种做法!注意,锁也会被多个线程同时访问的,锁也是临界资源!所以锁被设计出来本身就是原子的!
上面的代码中,一个线程刚抢完票,usleep(10),如果没有会怎么样
如果没有usleep模拟的业务,,在纯互斥的场景下如果锁资源分配不均匀,可能会导致线程饥饿的问题,因为线程抢夺锁资源的能力是不一样的。就比如,你手边有一块蛋糕, 你舍友离你十米开外然后跟你抢夺这块蛋糕他是抢不过你的!
没有usleep(10)我的部分输出结果:
the thread1 get ticket 4
the thread1 get ticket 3
the thread1 get ticket 2
the thread1 get ticket 1
i++和i–都不是原子的操作,那么用锁来保护临界资源,锁也是共享资源,多个线程同时访问锁也是在竞争锁,那么
pthread_mutex_lock(&lock)
的操作也必须是原子的,怎么做到
我们先来看pthread_mutex_lock(&lock)
加锁的伪代码
lock:
movb $0, %al
xchgb %al, mutex
if(al寄存器的内容 > 0) return 0;
else 挂起等待;
goto lock
一行汇编就是原子的,要么执行,要么不执行!在大多数体系中为了实现互斥锁操作,都提供了swap或exchange指令(注:指令集:CPU能直接识别的基本指令),该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。
pthread_mutex_lock(&lock)
加锁的原子性我们再来看pthread_mutex_unlock
解锁的伪代码
unlock:
movb $1,mutex;
唤醒等待mutex的线程;
return 0;
我们使用RAII风格对原生的锁进行一定的方式。注:RAII(Resource Acquisition Is Initialization)是一种 C++ 中的编程风格,它的核心思想是通过在对象的构造函数中获取资源,然后在对象的析构函数中释放资源。
class Mutex
{
public:
Mutex(pthread_mutex_t* lock)
:lock_(lock)
{}
void lock()
{
pthread_mutex_lock(lock_);
}
void unlock()
{
pthread_mutex_unlock(lock_);
}
~Mutex(){}
private:
pthread_mutex_t* lock_;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t *lock)
:mutex_(lock)
{
mutex_.lock();
}
~LockGuard()
{
mutex_.unlock();
}
private:
Mutex mutex_;
};
售票系统3.0:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
#define NUM 3
int ticket = 1000;
void *getTicket(void *args)
{
uint64_t number = (uint64_t)(args);
while (true)
{
// 出了这个花括号,临时对象就析构
{
// 临时的LockGuard对象, RAII风格的锁!
LockGuard lockGuard(&lock);
if (ticket > 0)
{
usleep(100);
cout << "the thread" << number << " get ticket "
<< ticket << endl;
ticket--;
}
else
break;
}
usleep(10);
}
}
int main()
{
vector<pthread_t> tids;
for (uint64_t i = 0; i < NUM; i++)
{
pthread_t tid;
pthread_create(&tid, nullptr, getTicket, (void *)i);
tids.push_back(tid);
}
for (auto tid : tids)
{
pthread_join(tid, nullptr);
}
return 0;
}