文章目录
前言
本文从一个模拟生活中的抢票程序的例子引入线程安全问题。
一、预备知识
1.线程的ID
用pthread_create创建一个线程,产生的线程ID存放在第一个参数之中,该线程ID和内核中的LWP不是一回事。pthread_create函数第一个参数指向一块虚拟内存单元,该内存单元的地址就是新创建线程ID,这个ID是线程库的范畴,而内核中LWP是进程调度的范畴,轻量级进程是OS调度的最小单位,需要一个数值来唯一标识该线程。
Linux并不提供真正的线程,只提供了LWP,但是程序员不关注LWP,只关注线程。因此,OS在OS与应用程序之间设计了一个原生线程库——pthread库。系统保存LWP,原生线程库可能存在多个线程,别人可以同时使用。OS只需要对内核执行流LWP进行管理,而提供给用户使用的线程接口等其他数据需要线程库自己来管理,线程库对线程的管理:先描述,再组织。
线程库实际上是一个动态库:
进程运行时,动态库加载到内存,然后通过页表映射到进程地址空间的共享区,此时进程的所有线程都能看到这个动态库:
每个线程都有自己独立的栈:主线程采用的栈是进程地址空间中原生的栈,其他线程采用的是共享区中的栈。
每个线程都有自己的struct_pthread,包含对应线程的属性;每个线程都有自己的线程局部存储(添加__thread,可以将一个内置类型设置为线程局部存储),包含对应线程被切换时的上下文。
每个线程在共享区都有一块区域对该线程进行描述,因此我们要找得到一个用户级线程只需要找到该线程内存块的起始地址,就可以获取该线程的信息。
线程函数起始是在库内部对线程属性进行操作,最后将要执行的代码交给对应的内核级LWP去执行,因此线程数据的管理是在共享区。
线程ID本质上是进程地址空间共享区的一个虚拟地址。
文件test.c
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<pthread.h>
4 void* start_routine(void* args)
5 {
6 while(1)
7 {
8 printf("new thread tid:%p\n", pthread_self());
9 sleep(2);
10 }
11 }
12 int main()
13 {
14 pthread_t tid;
15 pthread_create(&tid, nullptr, start_routine, nullptr);
16 while(1)
17 {
18 printf("main thread tid:%p\n", pthread_self());
19 sleep(1);
20 }
21 return 0;
22 }
2.局部存储的验证
设置一个全局变量g_val,让一个线程对它进行++操作,判断其他线程是否会受到影响:
文件test.c
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<pthread.h>
4 int g_val = 10;
5 void* start_routine(void* args)
6 {
7 const char* name = (const char*)args;
8 while(1)
9 {
10 printf("%s is running... g_val = %d, &g_val = %p\n",name, g_val, &g_val);
11 sleep(2);
12 ++g_val;
13 }
14 }
15 int main()
16 {
17 pthread_t tid;
18 pthread_create(&tid, nullptr, start_routine, nullptr);
19 while(1)
20 {
21 printf("main thread g_val:%d, &g_val = %p\n", g_val, &g_val);
22 sleep(1);
23 }
24 pthread_join(tid, nullptr);
25 return 0;
26 }
运行:
我们发现一个线程改变g_val会导致其它线程的g_val同步改变,即g_val所有线程共同只有一份。
给全局变量g_val加上__thread:
文件test.c
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<pthread.h>
4 __thread int g_val = 10;
5 void* start_routine(void* args)
6 {
7 const char* name = (const char*)args;
8 while(1)
9 {
10 printf("%s is running... g_val = %d, &g_val = %p\n",name, g_val, &g_val);
11 sleep(2);
12 ++g_val;
13 }
14 }
15 int main()
16 {
17 pthread_t tid;
18 pthread_create(&tid, nullptr, start_routine, nullptr);
19 while(1)
20 {
21 printf("main thread g_val:%d, &g_val = %p\n", g_val, &g_val);
22 sleep(1);
23 }
24 pthread_join(tid, nullptr);
25 return 0;
26 }
运行:
从运行结果来看,加上__thread的g_val就不在是共享的了,而是每个线程独有一份。添加__thread可以将一个内置类型设置为线程局部存储,使每个线程都有一份,介于全局变量和局部变量之间线程特有的属性。
3.线程的封装
如果我们想像C++一样使用线程,创建使用线程时,直接构造对象设置回调函数,对线程原生接口可以进行简单的封装:
文件Thread.hpp
1 #include<iostream>
2 using namespace std;
3 #include<unistd.h>
4 #include<pthread.h>
5 #include<assert.h>
6 #include<string>
7 #include<string.h>
8 #include<functional>
9 class Thread;
10 //上下文
11 class Context
12 {
13 public:
14 Thread* this_;
15 void* args_;
16 public:
17 Context():this_(nullptr),args_(nullptr)
18 {}
19 ~Context()
20 {}
21 };
22
23 //线程
24 class Thread
25 {
26 public:
27 typedef function<void*(void*)> func_t;
28 const int num = 1024;
29 Thread(func_t func, void* args, int number)
30 :func_(func)
31 ,args_(args)
32 {
33 char buffer[num];
34 snprintf(buffer, sizeof buffer, "thread-%d", number);
35 name_ = buffer;
36 Context* ctx = new Context();
37 ctx -> this_ = this;
38 ctx -> args_ = args_;
39 int n = pthread_create(&tid_, nullptr, start_routine, ctx);
40 assert(n == 0);
41 (void)n;
42 }
43 static void* start_routine(void* args)
44 {
45 Context* ctx = static_cast<Context*>(args);
46 void* ret = ctx -> this_ -> run(ctx -> args_);
47 delete ctx;
48 return ret;
49 }
50 void join()
51 {
52 int n = pthread_join(tid_, nullptr);
53 assert(n == 0);
54 (void)n;
55 }
56 void* run(void* args)
57 {
58 return func_(args);
59 }
60 ~Thread()
61 {}
62 private:
63 string name_;
64 pthread_t tid_;
65 func_t func_;
66 void* args_;
67 };
二、线程安全问题
全局变量g_val可以被多个线程同时访问,多个线程同时操作可能会出现问题。
1.抢票程序
下面模拟抢票系统的抢票过程,多个线程同时对共享资源tickets做–的操作:
文件test.cc
1 #include"Thread.hpp"
2 #include<memory>
3 int tickets = 1000;//票
4 void* get_ticket(void* args)
5 {
6 string name = static_cast<const char*>(args);
7 while(1)
8 {
9 if(tickets > 0)
10 {
11 usleep(1234);//把调用该函数的线程挂起一段时间,单位是微秒(百万分之一秒)
12 cout<<name<<" 正在抢票,剩余票数"<<tickets<<endl;
13 tickets--;
14 }
15 else
16 {
17 break;
18 }
19 }
20 return nullptr;
21 }
22 int main()
23 {
24 std::unique_ptr<Thread> thread1(new Thread(get_ticket, (void*)"HelloThread", 1));
25 unique_ptr<Thread> thread2(new Thread(get_ticket, (void*)"CoutThread", 2));
26 unique_ptr<Thread> thread3(new Thread(get_ticket, (void*)"PrintThread", 3));
27 unique_ptr<Thread> thread4(new Thread(get_ticket, (void*)"TestThread", 4));
28 thread1->join();
29 thread2->join();
30 thread3->join();
31 thread4->join();
32 return 0;
33 }
运行:
生活中抢票的剩余票数能是-1、-2吗?显然是不能的。
为啥结果会出现负数?
多个线程交叉执行任务。
多个线程交叉执行的本质:调度器尽可能的频繁发生线程调度与切换。
线程啥时候发生切换:时间片到了或者来啦更高优先级的线程或者线程等待的时候。
线程什么时候检测上面的问题(啥时候判断线程是否需要切换):从内核态返回用户态时,线程要对调度状态进行检测,如果满足上面的某个条件就发生线程切换。在tickets = 1时,所有线程都可以进去:1.读取内存中tickets的值加载进CPU的寄存器中。2.判断tickets是大于0:第一个线程判断结束,将线程切换走(寄存器中有一个,里面放置的是当前执行流的上下文,当线程切换时会把上下文带走)此时还没进行–操作,因此其它线程看到的tickets也是1,因此其它进程判断tickets也是满足大于0的。3.在进行–操作之前会将线程挂起休眠一会,等线程被唤醒时线程会切换回来进行–操作。
–操作的本质是:1.读取数据、2.更改数据、3.写回数据。
2.问题分析
对一个全局变量进行多线程操作是不安全的:
对该变量进行++或–操作,在高级语言层面上看是一条语句,但是经过汇编后至少是三条语句:
1.从内存读取数据到CPU寄存器中;2.在寄存器中让CPU进行对应的算术逻辑运算;3.将运算结果写回内存中该变量的位置(修改内存中的数据)
现在线程1将数据加载到寄存器中,进行–,数据变为999,到了写回这一步之前线程被切走了,当然是跟它的上下文一起被切走:
接着调度线程2,线程2可以运行的时间比较长,因此它可以一直进行–操作,直到数据减到100,线程2被切走了,跟着线程2的上下文一起。现在线程1回来了,线程1恢复自己的上下文,继续–的第三步操作,将内存里线程2好不容易–到的100恢复为了999。
由此可知定义的全局变量在没有任何保护的情况下,是不安全的。上面例子中,多线程交替执行造成了数据安全问题(数据不一致的问题)。
解决这种问题的办法就是加锁!!!
三、Linux线程互斥
1.概念
临界资源
多个执行流进行安全访问的共享资源,就是临界资源。
临界区
多个执行流进行访问临界资源的代码(上下文),称为临界区。
互斥
任何时刻,互斥保证有且仅有一个执行流进入临界区访问临界资源(可以对临界资源起保护作用)。
原子性
不会被任何调度机制打断的操作,因为该操作只有两态:要么做,要么不做。这就是原子性。
- 对一个变量进行操作,如果只有一条汇编语句就能完成,那么该操作就是原子的,反之就不是原子的。
对变量++或–,在高级语言上来看是一条语句,但是汇编之后至少三条语句,因此它不是原子的。(会导致数据不一致的问题) - 对一个资源进行访问,要么不做,要么做完。如果线程被切换导致没有做完访问的操作或者有中间状态,那么都不是原子的操作。
2.互斥量
概念
一般情况下,线程使用的变量都是局部变量,变量的地址空间在线程的栈空间内,这种情况,变量归属单个线程,其它线程无法获得这种变量。
也有以一些变量需要在线程间共享,这也的变量称为共享变量,可以通过它来进行线程间的交互。
多线程并发的操作共享变量(共享资源),可能会导致数据不一致的问题。为了解决该问题,我们要对共享资源做保护。
如何保护共享资源?
线程之间在并发操作共享资源时必须要互斥,当一个线程进入该资源的临界区时,不允许其它线程进入该临界区。
如果多个线程同时要求执行临界区的代码,并且此时临界区并没有线程进行执行,那么只允许其中一个线程进入该临界区。
如果线程不做临界区中,即线程已经执行完对该临界资源的操作,那么该线程就不能组织其它线程进入临界区。
如何保证互斥性呢?给临界资源加一把锁,只有持有该锁的线程可以进入临界区,其它线程无法访问临界区,直到对应线程将锁归还。Linux提供的这把锁就叫做互斥量(mutex)
接口
常见的相关接口
//初始化
int pthread_mutex_init(pthread_mutex_t* restrict mutex, const pthread_mutexattr_t* restrict attr);
//销毁
int pthread_mutex_destroy(pthread_mutex_t* mutex);
//全局
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//返回值:成功返回0,失败返回-1
锁既可以定义成局部的,也可以定义成全局的。
pthread_mutex_t是锁的类型,如果定义的锁是全局的就不需要用pthread_mutex_int和pthread_mutex_destroy初始化和销毁了。
//加锁
int pthread_mutex_lock(pthread_mutex_t* mutex);
//尝试加锁,如果加锁成功直接持有锁,如果加锁不成功立马出错返回(试着加锁,非阻塞获取锁)
int pthread_mutex_trylock(pthread_mutex_t* mutex);
//解锁
int pthread_mutex_unlock(pthread_mutex_t* mutex);
//返回值:成功返回0,失败返回错误码
3.mutex的使用
全局锁的使用
还是使用之前的抢票小进程,有四个线程进行抢票,我们给抢票的过程加上全局锁:
定义全局锁,并初始化PTHREAD_MUTEX_INITIALIZER,同时用pthread_create创建四个线程进行测试。因为此时的锁是全局锁,所以我们不用将锁传给每个线程。
文件test.cc
1 #include"Thread.hpp"
2 #include<memory>
3 int tickets = 1000;//票
4 pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
5 void* get_ticket(void* args)
6 {
7 string name = static_cast<const char*>(args);
8 while(1)
9 {
10 pthread_mutex_lock(&lock);//加锁
11 if(tickets > 0)
12 {
13 usleep(1234);
14 cout<<name<<" 正在抢票,剩余票数"<<tickets<<endl;
15 tickets--;
16 pthread_mutex_unlock(&lock);//解锁
17 }
18 else
19 {
20 pthread_mutex_unlock(&lock);//解锁
21 break;
22 }
23 }
24 return nullptr;
25 }
26 int main()
27 {
28 std::unique_ptr<Thread> thread1(new Thread(get_ticket, (void*)"HelloThread", 1));
29 unique_ptr<Thread> thread2(new Thread(get_ticket, (void*)"CoutThread", 2));
30 unique_ptr<Thread> thread3(new Thread(get_ticket, (void*)"PrintThread", 3));
31 unique_ptr<Thread> thread4(new Thread(get_ticket, (void*)"TestThread", 4));
32 thread1->join();
33 thread2->join();
34 thread3->join();
35 thread4->join();
36 return 0;
37 }
局部锁的使用
要用局部的锁,需要将锁传给每个线程,因此我们可以定义一个结构体ThreadData,存放着线程名与锁:
文件test.cc
1 #include<iostream>
2 using namespace std;
3 #include<pthread.h>
4 #include<string>
5 #include<unistd.h>
6 #include<vector>
7 #define NUM 4
8 int tickets = 1000;//票
9 class ThreadData
10 {
11 public:
12 ThreadData(const string& threadname, pthread_mutex_t* mutex_p)
13 :threadname_(threadname)
14 ,mutex_p_(mutex_p)
15 {}
16 ~ThreadData(){}
17 public:
18 string threadname_;
19 pthread_mutex_t* mutex_p_;
20 };
21 void* get_ticket(void* args)
22 {
23 ThreadData* td = static_cast<ThreadData*>(args);
24 while(1)
25 {
26 pthread_mutex_lock(td -> mutex_p_);//加锁
27 if(tickets > 0)
28 {
29 usleep(1234);
30 cout<<td -> threadname_<<" 正在抢票,剩余票数"<<tickets<<endl;
31 tickets--;
32 pthread_mutex_unlock(td -> mutex_p_);//解锁
33 }
34 else
35 {
36 pthread_mutex_unlock(td -> mutex_p_);//解锁
37 break;
38 }
39 }
40 return nullptr;
41 }
42 int main()
43 {
44 pthread_mutex_t lock;
45 pthread_mutex_init(&lock, nullptr);
46 vector<pthread_t> tids(NUM);
47 for(int i = 0;i < 4; ++i)
48 {
49 char buffer[64];
50 snprintf(buffer, sizeof buffer, "thread %d", i + 1);
51 ThreadData* td = new ThreadData(buffer, &lock);
52 pthread_create(&tids[i], nullptr, get_ticket, td);
53 }
54 for(const auto &tid:tids)
55 {
56 pthread_join(tid, nullptr);
57 }
58 pthread_mutex_destroy(&lock);
59 return 0;
60 }
运行:
此时运行结果每次都是减到1,但是运行的速度变慢了。因为加锁和解锁的过程是多个线程串行执行的,因此程序运行就变慢了。
还有一个问题,我们发现此时的程序,每次都只有一个线程抢票。这是因为锁只规定了互斥访问,并没有规定谁先执行谁后执行(并没有规定优先级),因此是哪个线程的竞争力强就由哪个线程来持锁。
那么如何解决这个问题呢?
我们细想一下,生活中抢票就仅有抢到票的那一下就结束了吗?显然不是的,生活中抢到票之后还有后续工作要完成,比如填写具体信息、完成订单等等。
因此我们可以让抢票程序抢到之后休息一下,让线程usleep(1234),假设这段时间是系统在生成订单信息。
文件test.cc
1 #include<iostream>
2 using namespace std;
3 #include<pthread.h>
4 #include<string>
5 #include<unistd.h>
6 #include<vector>
7 #define NUM 4
8 int tickets = 1000;//票
9 class ThreadData
10 {
11 public:
12 ThreadData(const string& threadname, pthread_mutex_t* mutex_p)
13 :threadname_(threadname)
14 ,mutex_p_(mutex_p)
15 {}
16 ~ThreadData(){}
17 public:
18 string threadname_;
19 pthread_mutex_t* mutex_p_;
20 };
21 void* get_ticket(void* args)
22 {
23 ThreadData* td = static_cast<ThreadData*>(args);
24 while(1)
25 {
26 pthread_mutex_lock(td -> mutex_p_);//加锁
27 if(tickets > 0)
28 {
29 usleep(1234);
30 cout<<td -> threadname_<<" 正在抢票,剩余票数"<<tickets<<endl;
31 tickets--;
32 pthread_mutex_unlock(td -> mutex_p_);//解锁
33 }
34 else
35 {
36 pthread_mutex_unlock(td -> mutex_p_);//解锁
37 break;
38 }
39 usleep(1234);//系统生成订单信息
40 }
41 return nullptr;
42 }
43 int main()
44 {
45 pthread_mutex_t lock;
46 pthread_mutex_init(&lock, nullptr);
47 vector<pthread_t> tids(NUM);
48 for(int i = 0;i < 4; ++i)
49 {
50 char buffer[64];
51 snprintf(buffer, sizeof buffer, "thread %d", i + 1);
52 ThreadData* td = new ThreadData(buffer, &lock);
53 pthread_create(&tids[i], nullptr, get_ticket, td);
54 }
55 for(const auto &tid:tids)
56 {
57 pthread_join(tid, nullptr);
58 }
59 pthread_mutex_destroy(&lock);
60 return 0;
61 }
运行:
这样就解决了上面提出的问题。
总结
以上就是今天要讲的内容,本文介绍了线程安全的相关概念,从抢票系统引入线程安全问题,再一步步解决问题·。本文作者目前也是正在学习Linux相关的知识,如果文章中的内容有错误或者不严谨的部分,欢迎大家在评论区指出,也欢迎大家在评论区提问、交流。
最后,如果本篇文章对你有所启发的话,希望可以多多支持作者,谢谢大家!