抢票问题
这里用上一篇:
的封装函数。
这里还需要用一个函数:
这里是以微妙做单位进行休眠的。
假设有1000张火车票,一共四个接口在抢,最后我们要看到什么现象呢?
因为多个线程进行交叉执行。
多个线程交叉执行本质:就是让调度器尽可能的频繁发生线程调度与切换。
线程一般在什么时候发生切换?当时间片到了,来了更高优先级的线程,线程等待的时候。
那么线程是什么时候检测上面的问题?是从内核态切换到用户态的时候,线程要对调度状态进行检测,如果可以,就直接发生线程切换。
#include "Thread.hpp" int tickets = 1000;//票数 void* thread_run(void* args) { string name = static_cast<const char*>(args); while(true) { if(tickets > 0) { usleep(1234);//1秒=1000毫秒=1000000微秒 cout << name << "用户正在抢票:"<< tickets-- <<endl; } else break; } } int main() { unique_ptr<Thread> thread1(new Thread(thread_run, (void*)"user1",1)); unique_ptr<Thread> thread2(new Thread(thread_run, (void*)"user2",2)); unique_ptr<Thread> thread3(new Thread(thread_run, (void*)"user3",3)); unique_ptr<Thread> thread4(new Thread(thread_run, (void*)"user4",4)); thread1->join(); thread2->join(); thread3->join(); thread4->join(); return 0; }
但是结果竟然出现了0,-1,-2,为什么会发生这种现象呢?
首先:判断的逻辑有两步
1.读取内存数据到CPU的寄存器中。
2.进行判断
因为CPU只有一个,每次线程只能有一个进行判断。
线程1运行的时候,CPU将tickets的数据放进内存其中与线程1的数据进行对比,但是对比结束之后,突然时间片到了,线程切换,线程2也进行了如上步骤,刚对比完又切换了。
如果极端场景,四个进程都在这个时候对比,tickets的数据一直都是1,那么这个时候线程1被唤醒,线程1带着他的上下文回到CPU,CPU处理这段代码,tickets的数据进行- - ,处理完又去处理线程2,线程3,线程4。
这也就导致了出现0,-1,-2的结果。
还有另一种情况。
对一个全局变量进行多线程更改,这个操作也不是安全的。
对于++,- -这两种操作,在C,C++上看起来只有一条语句,其实汇编用了三条语句。
1.从内存中读取数据到CPU寄存器中。
2.在寄存器中让CPU进行对应的逻辑运算。
3.写回新的结果到内存中变量的位置。
假设线程1先将票数减少了333张。
然后CPU本来要将666传给内存中的票数时突然进行了线程切换,到了线程2一看票数还是999。
于是线程2开始继续抢票:
线程2将票数减少到了222,这个时候,又换回了线程1.
这个时候首先恢复的是上下文,然后更新内存中的数据,一下子变成了666,之前变成222等于白做事情了。
总结:我们定义的全局数据在没有保护的时候往往是不安全的,像上面多个线程在交替执行造成的数据安全问题,发生数据不一致。
那么如何解决呢?
互斥锁
锁的接口
之前说过原子性是要么做,要么不做,这里再结合上面抢票问题说一下。
像上面进行++操作,需要三条汇编语言,CPU在执行的时候是一定会将一条汇编语言执行完毕(失败与否不关心),也就是说这会导致有中间状态,是可以被打断的,这就叫做非原子性。
那么原子性其实就是一个对资源进行的操作,如果只用一条汇编能完成,这个就是原子性,反之就不是原子性。
这个时候,对于以上提出问题的解决方案叫做加锁。
锁也有对应的函数:
这把锁的类型是:
第一个函数是释放锁,第二个函数是初始化锁
这里是对全局定义的锁初始化方式。
这里第一个函数是对对应的锁进行加锁。
第三个函数是解锁。
#include "Thread.hpp" int tickets = 1000;//票数 pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;//全局锁 void* thread_run(void* args) { string name = static_cast<const char*>(args); while(true) { pthread_mutex_lock(&lock);//这里进行加锁 if(tickets > 0) { usleep(1234);//1秒=1000毫秒=1000000微秒 cout << name << "用户正在抢票:"<< tickets-- <<endl; pthread_mutex_unlock(&lock);//解锁 } else { pthread_mutex_unlock(&lock); break; } } } int main() { unique_ptr<Thread> thread1(new Thread(thread_run, (void*)"user1",1)); unique_ptr<Thread> thread2(new Thread(thread_run, (void*)"user2",2)); unique_ptr<Thread> thread3(new Thread(thread_run, (void*)"user3",3)); unique_ptr<Thread> thread4(new Thread(thread_run, (void*)"user4",4)); thread1->join(); thread2->join(); thread3->join(); thread4->join(); return 0; }
在运行的过程中,这个速度也变慢了,因为现在的线程是串行执行,所以也没有发生之前奇怪的打印结果。
那么为什么一直都是一个线程抢到票了呢?这是因为锁虽然规定了串行执行,但是并没有去管理线程的竞争,这里第四个线程竞争力最强,所以每次都是线程4抢到票。
这里我们在用一下局部锁,并且解决一下刚才的问题。
#include "Thread.hpp" int tickets = 1000;//票数 class ThreadData { public: ThreadData(const string& threadname, pthread_mutex_t *mutex_p):_threadname(threadname),_mutex_p(mutex_p) {} ~ThreadData() {} public: string _threadname; pthread_mutex_t *_mutex_p; }; void* thread_run(void* args) { ThreadData* p = static_cast<ThreadData*>(args); while(true) { pthread_mutex_lock(p->_mutex_p);//加锁 if(tickets > 0) { usleep(1234);//1秒=1000毫秒=1000000微秒 cout << p->_threadname << "用户正在抢票:"<< tickets-- <<endl; pthread_mutex_unlock(p->_mutex_p); } else { pthread_mutex_unlock(p->_mutex_p); break; } usleep(1234);//模拟抢完票形成一个订单,这里也就等于阻止了竞争力强的线程,让竞争力强的到后面排队去 } } int main() { pthread_mutex_t lock; pthread_mutex_init(&lock, nullptr);//初始化锁,第二个参数设为nullptr就可以 vector<pthread_t> arr(4); for(int i = 0;i < 4; i++) { char buffer[64]; snprintf(buffer,sizeof(buffer),"thread %d",i+1); ThreadData* p = new ThreadData(buffer, &lock); pthread_create(&arr[i], nullptr, thread_run, p); } for(const auto& e:arr) { pthread_join(e,nullptr); } pthread_mutex_destroy(&lock);//解锁 return 0; }
理解锁
锁的背景概念
临界资源:多线程执行流共享的资源就叫做临界资源。
临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
如何看待锁:
1.我们在使用锁的时候,锁能被每个线程都看到,所以锁本身就是共享资源。锁是保护资源的,那么锁的安全谁来保护呢?
2.pthread_mutex_lock枷锁的过程中必须是安全的。(其实就是原子的)
3.如果申请成功,就继续向后执行,如果失败执行流就阻塞。
注意,我这里申请了两次加锁。
这里就阻塞了。
4.谁持有锁,谁就进入临界区。
假如线程1持有锁,进入临界资源,其他线程在阻塞,那么在这个过程中线程1是可以被切换的。
这也说明,线程1是和锁一起被切走了。
所以对于其他线程而言,线程1有意义的状态只有两个。
申请锁前
释放锁后
站在其他线程角度,看待当前线程持有锁的过程就是原子的。
未来我们在使用锁的时候,一定要尽量保证临界区的粒度(锁中间保护的代码)非常小。
并且,加锁是程序员的行为,针对某一处公共资源,对于一个线程加锁,其他线程也要想办法加锁。