线程
为什么需要引入线程?
- 一方面是计算机多核的提升,使得计算机的并行度越来越高,如果能够运行多个程序,将一个程序划分为多个线程同时执行,就比如一个程序一个进程由一步一步去做,和划分为好几个模块去分开由多个CPU去做,时间效率上高出了不少。
- 另一方面是进程都拥有独立的虚拟空间,所以在某些场景下数据的共享和同步比较麻烦,一般只能通过进程间通信,或者共享虚拟内存页来实现,因此设计了线程,在进程内部添加了可执行的单元,线程之间就可以共享进程的地址空间,但又各自保存着自己的状态,来实现数据的共享和同步。
因此,这意味着进程的数据段,代码段等数据被多个线程进行共享。
因此,什么是线程?
- 是进程当中的一个执行流程,是操作系统中执行的最小单位。
// 线程示例
void* thread_example(void* arg) {
int thread_id = *(int*)arg;
printf("这是线程,线程ID:%d\n", thread_id);
pthread_exit(NULL);
}
int main() {
// 创建进程
process_example();
// 创建线程
pthread_t thread;
int thread_id = 1;
int ret = pthread_create(&thread, NULL, thread_example, &thread_id);
if (ret != 0) {
fprintf(stderr, "线程创建失败\n");
exit(1);
}
// 等待线程结束
pthread_join(thread, NULL);
return 0;
}
1.用户级线程和内核态线程
先来理解用户空间和内核空间,也就是将内存分为两个区域,我们的操作系统内核将内存分为用户内存和操作系统内存,一方面是保护了操作系统进程的安全性,一方面是隔离开来,对用户级和内核级程序进行分开管理。
- 内核空间:仅仅只能内核程序运行使用
- 用户空间:这部分给用户程序使用
因此我们将内存区域划分为用户态和内核态:
因此运行在内的程序可以被划分为用户级进程和内核级进程
-
其次,进程之下可以有多个线程去执行,因此线程也被划分为用户态线程和内核态线程
- 用户态线程:由于用户态线程的创建是通过用户内进程自己创建的,面向对象是本身,所以内核并不可知,因此他并不受系统调度器的管理,和内核态相比,用户态线程确实更加轻量,开销更小,由此功能就没那么牛逼,与内核态相关的操作,比如系统的某些功能调用,则需要内核态线程协助才能实现。
- 内核态线程:由于内核态线程由操作系统内核创建,即操作系统自知,受到操作系统的系统调度器的管理,而多线程的情况下,内核级进程并不方便管理过多的内核态线程,因此数量不易过多
-
为什么用户态线程需要去和内核态线程去协作?
用户态线程往往需要调用系统级别的资源时,并不是通过自身直接获得的,而是通过内核态线程去获得,相当于自身权级不够,所以委托给内核态去完成。
- 系统级别的资源是什么?
- 访问底层硬件资源:许多底层硬件资源(如磁盘、网络接口、外设等)和操作系统提供的功能(如文件系统、网络协议栈等)需要操作系统内核来管理和控制。
- 系统调度和资源管理:操作系统负责对整个系统中的资源进行调度和管理,包括分配CPU时间片、内存管理、文件和设备的访问等。
- 安全和权限控制:操作系统通常具有安全和权限控制的机制,以保护系统和用户数据的安全性。用户级线程无法直接控制和访问这些机制,因此需要通过操作系统接口来执行安全检查、权限验证和访问控制,以确保用户级线程的操作符合系统规则和用户权限。
因此,用户态线程离不开内核,也离不开内核态中的线程调用,也就是协作。
1.1多线程模型
多线程模型是一种对应关系
,这里指的是用户态线程和内核态线程的这种映射关系.
其实这种关系的映射不光在操作系统的线程有所使用,数据库中的对象关系映射(Object-Relational Mapping,ORM)也存在,这种设计思想可以说为开发人员提供了一种抽象和一种方法,用来解决特定的问题,其实这种设计思想的理念来源于生活.
- 多对一:将多个用户态线程映射给单一的一个内核态线程,这意味着什么,同一时刻只能有一个用户态线程被处理,这就意味着银行办理业务的柜台只有一个,如果三个用户都想去取钱(获取硬件资源),这就意味着在同一时刻,需要等待和排队,这种关系模型在早期单核CPU应该是广为使用.
- 一对一:一对一的这种设计关系就比较土豪了,为每一个用户态线程都分配对应的一个内核态线程,可以想象内核态线程能做很重要的事,而这样的内核态线程可以说可遇不可求,也就是不符合现实情境,为每一个用户态线程都分配一个内核态线程这种关系显然是不现实的,随着如今大量的应用程序和主存的发展,内核态线程的数量的增率远远赶不上用户态线程,更何况内核态线程的开销会增加操作系统的负担.但如今Linux和Windows都采用的这种模型,原因是他们对于用户态线程做了限制.
- 多对多:因此,多对多模型其实可以算是一种对内核态线程的减轻,让用户态线程和内核态线程之间的这种映射关系并不在确定,谁空闲了就让谁管理,就如同如今的银行柜台,哪里空闲就去哪里,因此也减轻了性能开销的问题.
1.2线程控制块(TCB)
其实无论是TCB还是PCB,他们的名字都很直白都很孕育含义,(Thread Control Block,TCB),因此TCB也用来保存自身的相关信息,和PCB相似,也会存储线程的运行状态,内存映射,标识符等.
typedef struct {
pid_t tid; // 线程ID
int state; // 线程状态
int priority; // 线程优先级
void *stack; // 线程栈指针
size_t stack_size; // 线程栈大小
// 其他线程相关的信息和数据
} tcb_t;
- 因此在多线程的情况下,每一个线程都具备自己的TCB和栈,寄存器等资源,线程之间虽然共享进程的内存数据,但有时我们也需要线程之间互相隐瞒,这种隐藏,就是一种设计理念,又允许多线程共同完成工作,又想要多线程完成自己相应的工作不被其他线程打扰,因此就有了线程局部存储(线程本地存储 TLS)这种机制.
TLS允许每个线程拥有一份独立的数据副本,线程可以独立地读取和修改自己的数据,而不会影响其他线程的数据。这样可以在多线程环境下实现线程间的数据隔离,使得每个线程可以维护自己的状态和上下文。
在C/C++语言中,可以使用thread_local
关键字来声明线程局部变量。这样声明的变量会在每个线程中分配一份独立的内存空间,每个线程可以通过该变量来访问自己的数据。
#include <iostream>
#include <thread>
thread_local int tls_var = 0;
void foo() {
tls_var += 1;
std::cout << "Thread " << std::this_thread::get_id() << ": tls_var = " << tls_var << std::endl;
}
int main() {
std::thread t1(foo);
std::thread t2(foo);
t1.join();
t2.join();
return 0;
}
输出结果:
Thread Thread 3 : tls_var = 12; tls_var= 1
我们使用thread_local
关键字声明了一个整型变量tls_var
,然后在foo()
函数中对该变量进行递增操作并输出。当我们创建两个线程t1
和t2
,它们分别执行foo()
函数。由于tls_var
是线程局部变量,每个线程都有自己的副本,并且可以独立地进行操作,因此输出的两个var隶属于不同的两个值
2.线程的创建
-
在Linux中,我们以POSIX线程库为例(因为我只知道这个),也被称为pthreads.
#include <pthread.h>
-
创建线程:使用pthread_create函数创建新线程,并指定线程函数和参数。
pthread_t thread; int result = pthread_create(&thread, NULL, thread_function, NULL); if (result != 0) { // 处理线程创建失败的情况 }
- 第一个参数用来指向pthread_t的指针,用于创建新的线程,第二个参数一般用来设置线程的属性,而第三个参数则是指向函数的入口,将函数作为入口,以此来执行,第四个参数则是用来向新线程传递参数的.
- 因此,上述线程的创建含义是:创建一个新线程,并将线程标识符存储在
thread
变量中,使用默认的线程属性,线程的入口函数为thread_function
,并且没有传递参数给thread_function
函数。
-
线程的执行:
void* thread_function(void* arg) { // 对传递的参数进行处理 int value = *(int*)arg; // 线程执行的代码 // ... return NULL; }
3.线程的退出
线程的退出实现有很多种方式
-
return:线程函数执行完毕并返回时,线程会自动退出
void* thread_function(void* arg) { // 线程执行的代码 return NULL; // 线程退出 }
-
调用
pthread_exit
函数:在线程函数中主动调用pthread_exit
函数可以立即退出线程,并将指定的返回值传递给线程的创建者。#include <pthread.h> void* thread_function(void* arg) { // 线程执行的代码 pthread_exit(NULL); // 线程退出 }
-
取消线程:可以使用
pthread_cancel
函数取消一个正在执行的线程。这种方式会向线程发送一个取消请求,线程在遇到取消点时会退出。线程可以通过设置取消状态和取消类型来控制是否响应取消请求。#include <pthread.h> void* thread_function(void* arg) { // 线程执行的代码 pthread_testcancel(); // 检测取消请求 // ... pthread_testcancel(); // 检测取消请求 // ... pthread_cancel(pthread_self()); // 取消线程 pthread_exit(NULL); }
由于线程的退出其实并不会影响其他线程,进程中的其他线程依然可以继续的工作,但需要这主意到主线程(通常情况也就是main函数),主线程的一般退出意味着其他的线程也会结束,整个进程意味着结束,因此可以使用pthread_join
函数等待其他线程执行完毕.
-
线程的主动让出资源:
yield
:也就是当前CPU主动让出CPU,交给其他线程#include <pthread.h> int pthread_yield(void);
因此,在调用此函数的时候,也不需要任何参数,也就意味着主动放弃CPU资源,这很人性化其实,参考现实世界,计算机中也不一定是所有线程无时无刻都是具备相应的作用的,因此可以放弃CPU,腾出其他资源给更有用的(线程).....
-
线程的挂起与唤起:
sleep
和cond_signal
一个用来挂起,一个用来唤醒,线程的挂起往往是因为进入了阻塞状态,将资源交给了其他线程执行,而挂起一般根据策略也可以划分为两类:-
等待固定的时间
#include <unistd.h> unsigned int sleep(unsigned int secounds);
sleep
会让线程挂起数秒,这个秒数可以自行决定,也可以使用Random
随机决定. -
等待具体的事件
#include <pthread.h> int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
也就是当条件满足时,他会将此线程挂起,并释放互斥锁(同一时间只有一个线程能访问,保证了数据的安全性和可靠性,但是可能会出现死锁)
这种方式也就是条件变量来实现同步,后续再了解
-
pthread_cond_signal()
:该函数用于唤醒等待在条件变量上的某个线程。它会选择一个等待在条件变量上的线程,并将其唤醒。如果没有线程等待在条件变量上,则不进行任何操作。#include <pthread.h> int pthread_cond_signal(pthread_cond_t *cond);
-
pthread_cond_broadcast()
:该函数用于唤醒等待在条件变量上的所有线程。它会唤醒所有等待在条件变量上的线程,使得它们可以继续执行。#include <pthread.h> int pthread_cond_broadcast(pthread_cond_t *cond);
-
3.1 sleep
和yield
其实这两种方法都很有意思,让当前线程放弃资源,转交给其他线程,先来理解在运行时调用这两类方法意味着什么,同进程一样,线程也具备相应的状态,也就是新建
,就绪
,运行
,阻塞
,等待
,超时等待
,结束
.我们先来理解这两种方法会让运行态的线程发生什么?
这就是他们底层的不同,由于yield被调用后,线程依旧有可能很快进入运行态,也就是被继续调用,所以他的状态为就绪态,甚至极端情况下,他会继续执行,而调用sleep必须要满足一定的条件才可以执行,这就是为什么yield中没有参数的原因.
- 注:java中的
sleep
和yield
其设计思想以及层次基本是一样的,可能具体的实现步骤不一样.
3.2sleep
和wait
在操作系统级别,wait
是一个用于进程间通信的方法,而sleep
是一个用于线程间通信的方法,可我为什么要说他们呢.因为有人经常拿他们比较.
来看一下java中的sleep
和wait
sleep
:Thread下的方法,也就是线程层次的方法,和操作系统其设计的思想理念基本一致,但在java中sleep
并不会影响线程获得某个对象资源的锁的状态,他的本质和操作系统线程级别的sleep
含义是一样的,休眠一定时间,因此sleep
不存在加锁机制!!!!wait
:Object下的方法,java之所以被称之为面向对象设计语言,可见对象二字的含金量,如果说计算机早期因为进程奔波东西,那java就一直在为找对象的路上奔波东西,所以wait是用于让线程进入等待态
并进入该对象的等待队列,直到其他线程发出通知。相当于一个乖小孩乖乖等待,等待线程的召唤,因此此对象的锁会被释放就是这个原因.笼统上看貌似对象的粒度并不如线程的粒度大,但都无疑解决了一个资源性的问题
其实理解这个的最好实例就是生产者消费者问题:
3.4生产者消费者问题
两个问题:
- 生产者在已经缓冲区满的情况下依旧生产
- 消费者在已经缓冲区为空的情况下依旧消费
举例:这里缓冲容量为5:
-
Message信息:
class Message { private String content; private boolean hasNewMessage; public synchronized void setMessage(String content) { while (hasNewMessage) { try { // 等待,直到已有消息被消费 wait(); } catch (InterruptedException e) { e.printStackTrace(); } } this.content = content; hasNewMessage = true; System.out.println("Producer: Message produced - " + content); // 唤醒等待中的消费者线程 notify(); } public synchronized String getMessage() { while (!hasNewMessage) { try { // 等待,直到有新消息被生产 wait(); } catch (InterruptedException e) { e.printStackTrace(); } } String message = content; hasNewMessage = false; System.out.println("Consumer: Message consumed - " + message); // 唤醒等待中的生产者线程 notify(); return message; } }
-
生产者:
class Producer implements Runnable { private Message message; public Producer(Message message) { this.message = message; } @Override public void run() { for (int i = 0; i < 5; i++) { String content = "Message " + i; message.setMessage(content); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } }
-
消费者:
class Consumer implements Runnable { private Message message; public Consumer(Message message) { this.message = message; } @Override public void run() { for (int i = 0; i < 5; i++) { String content = message.getMessage(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } }
-
主线程(main):
public static void main(String[] args) { Message message = new Message(); Thread producerThread = new Thread(new Producer(message)); Thread consumerThread = new Thread(new Consumer(message)); producerThread.start(); consumerThread.start(); }
被synchronized修饰,因此是一个同步方法,在同一时刻,只允许一个线程进入,因此synchronized方法之间是互斥的
调用Object的
wait
方法,因此释放锁-
先来看一下Message中的字段信息代表的实际含义:
private String content;//这里代表的资源,可以是任意类型,为了方便观看,选择的string private boolean hasNewMessage;//这里代表的是资源是否还有或者为满的标志位判断
-
再来看一下SetMessage
public synchronized void setMessage(String content) { while (hasNewMessage) { try { // 等待,直到已有消息被消费 wait(); } catch (InterruptedException e) { e.printStackTrace(); } } this.content = content; hasNewMessage = true; System.out.println("Producer: Message produced - " + content); // 唤醒等待中的消费者线程 notify(); }
在类初始化的开始时候,hasNewMessage字段为false,表示现在没有生产资料,因此在资料区未满之前,都不走while循环,每次将当前的生产资料赋值给content字段,并将hasNewMessage字段更新为有生产资料的状态,并唤醒消费者进程(因此此时消费者进程其实可能已经运行了,不过线程进入了等待的状态).
-
再来看一下GetMessage
public synchronized String getMessage() { while (!hasNewMessage) { try { // 等待,直到有新消息被生产 wait(); } catch (InterruptedException e) { e.printStackTrace(); } } String message = content; hasNewMessage = false; System.out.println("Consumer: Message consumed - " + message); // 唤醒等待中的生产者线程 notify(); return message; }
同样,资料区为空的时候,他会进入等待,直到生产者进程生产资料并将他唤醒.在此之前他都会处于等待状态
-
-
而while主体的部分则确保了在没有资料时(这意味着消费者要去消费),会去等待生产者去生产,因此他需要wait方法来释放此对象的锁,否则生产者将永远无法生产,同样,对于资料已满时(这意味着生产者要去生产),会去等待消费者去消费,因此他也需要wait方法来释放此对象的锁,否则消费者将永远无法消费.而hasNewMessage字段则保证了每生产一次就意味着一次消费,且顺序一定,否则另一个线程则进入阻塞状态,等待调用者调用wait方法来释放锁,因此输出的结果为:
Producer: Message produced - Message 0 Consumer: Message consumed - Message 0 Producer: Message produced - Message 1 Consumer: Message consumed - Message 1 Producer: Message produced - Message 2 Consumer: Message consumed - Message 2 Producer: Message produced - Message 3 Consumer: Message consumed - Message 3 Producer: Message produced - Message 4 Consumer: Message consumed - Message 4
为此,整个生产者消费者问题就解决了,如果对资源的生产消费不加以上锁,则会导致问题:
Exception in thread "Thread-1" Exception in thread "Thread-0" java.lang.IllegalMonitorStateException
为此可以对比一下进程间的wait
和java线程中的wait
- 同为
wait
,他们解决了资源的问题,前者解决了僵尸进程,也就是PCB占用浪费的情况,否则PCB则一直占用,后者解决了线程的同步的问题,没有让资源处于阻塞的状态(也是被占用),这种设计思想可以说十分相似(一方是面对进程资源的占用,一方是面对对象资源的占用),所以深度和广度的学习其实是相辅相成的!!