前面学习了进程、文件等概念,接下里为大家引入线程的概念
多线程
- 线程是什么?
- 为什么要有线程?
- 线程的优缺点
- Linux线程操作
- 线程创建
- 线程等待
- 线程终止
- 线程分离
- 线程间的私有和共享数据
- 理解线程库和线程id
- 深刻理解Linux多线程(重点)
线程是什么?
- 线程是一个执行分支,执行粒度比进程更细,调度成本更低。
- 线程是进程内部的一个执行流
- 线程是CPU调度的基本单位,进程是承担分配系统资源的基本实体
为什么要有线程?
多线程编程在现代计算中具有显著的优势,主要体现在以下几个方面:
- 发挥多核CPU优势:
现代处理器普遍拥有多个核心,多线程能够确保这些核心得到充分利用。通过创建多个线程,程序可以同时在不同核心上执行不同的任务部分,从而提升并行处理能力,整体提高程序的运行效率。- 防止阻塞和提高响应性:
当一个线程在等待IO操作(如磁盘读写、网络通信)或执行耗时计算时,操作系统可以调度其他线程继续执行,避免了整个进程停滞,提高了系统的响应速度。- 并发处理能力增强:
多线程使得系统能够并发地处理多个用户请求或执行多个独立的任务。这对于高并发场景下的服务器应用、实时数据处理、大规模并行计算等尤为关键,可以显著提升系统吞吐量和服务质量。- 模块化与简化设计:
在复杂的软件架构中,多线程有助于将大的任务分解成若干个可管理的子任务,并分配给不同的线程来处理。这不仅使得代码逻辑更加清晰,也更容易进行模块化开发和维护。- 改善用户体验:
用户界面应用程序中,主线程负责处理UI事件,后台线程则可以处理长时间运行的操作(例如加载数据、文件压缩解压)。这样可以在不影响用户界面交互的同时完成大量工作,提升了用户体验。- 资源利用率:
相对于为每个并发任务创建新的进程,线程间的切换开销较小,因为它们共享同一进程的地址空间和其他资源。这降低了系统资源消耗,尤其是在内存有限的情况下。
然而,多线程编程也带来了一系列挑战,包括但不限于:资源共享引发的数据竞争问题、死锁、优先级反转等并发控制难题。因此,在享受多线程带来的性能提升时,开发者需要精心设计和实现线程间同步机制,以保证程序的正确性和稳定性。
线程的优缺点
优点:
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
缺点:
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的 同步和调度开销,而可用的资源不变。- 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。(一个线程崩溃了,系统发送信号是以进程为单位的,所以整个进程都会崩溃。)- 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响(多线程共享地址空间)。
Linux线程操作
使用Linux线程接口的时候,我们需要导入线程库。例如:
g++ -o main main.cpp -std=c++11 -lpthread
这个pthread
就是Linux自带的线程库,但是我们上面线程概念提到了Linux没有真正的线程,而是用进程模拟的线程(LWP),所以Linux也没有真正的线程接口,Linux提供的是轻量级进程的系统接口,然后对这个接口进行封装,封装成线程库,在用户的角度看这就是线程控制的接口,从而完成对线程的控制。(任何操作系统都会自带线程库)
线程创建
线程创建使用pthread_create
这个函数接口,需要包含头文件pthread.h
参数解析:
- pthread_t *thread //线程id
- const pthread_attr_t *attr //设置线程属性,一般为nullptr
- void *(start_routine) (void ) //回调函数,会执行传入的函数指针(返回值void,参数void)
- void *arg //可以作为函数参数
线程等待
在主线程调用这个函数会让主线程阻塞等待线程结束,并不是所有的情况都必须调用这个函数。
传入对应的线程id,并且阻塞等待。第二个参数是一个输出型参数,用于接收线程返回值。
线程终止
线程终止可能由以下几种情况造成:
- 一个线程正常情况下结束有可能是线程函数执行结束了,return void*。
- 还有一种就是调用这个函数,线程调用这个函数让自己退出。
- 主线程调用这个函数就可以取消正在执行的线程。返回值为-1。
线程分离
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
分离线程的接口,只需要传入线程id就可以了。
线程分离之后是不可以join的,join会返回报错码,线程分离是一种属性状态,如果主线程join等待某个线程,会查看这个线程的状态是否是分离的。如果是分离状态就会直接报错。如果先join线程,再分离线程,会检测不到。
例如这样就会报错
void *run_thread(void *args){
//pthread_detach(pthread_t pthread_self()); //可以自己分离自己
int cnt = 5;
while(cnt)
{
cout<<"我是线程:"<<cnt--<<endl;
sleep(1);
}
}
int main(){
pthread_t t1;
pthread_create(&t1,nullptr,run_thread,nullptr);
pthread_detach(t1);//可以再主线程里面分离
int n = pthread_join(t1,nullptr);
if(n!=0)//如果n!=0表示阻塞等待失败,打印返回的错误码
{
cout<<"error:"<<n<<":"<<strerror(n)<<endl;
}
return 0;
}
线程间的私有和共享数据
线程共享进程数据,但是也拥有自己的一部分数据:
- 线程ID
- 一组寄存器
- 栈
- errno
- 信号屏蔽字
- 调度优先级
进程的多个线程共享 同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
理解线程库和线程id
我们学习的Linux里面其实是没有真正意义上的线程的,Linux用进程模拟的线程。所谓的线程就是一个轻量级的进程,所以Linux提供了对轻量级进程操作的接口,而我们用的线程库就是对这些接口进行了封装。从用户的角度是对线程进行操作,但是从OS的角度是对轻量级进程的操作。
这个线程库是一个用户级的动态库,所以进程想要用这个库,必须先加载到内存,然后映射到进程地址空间的共享区中。
这个线程库里面可能会创建很多个线程,所以需要对线程进行管理,所以先描述,再组织。每个线程都有一个结构体来对这些线程进行统一管理。
线程库中有很多个线程,每个线程都是向数组一样排列,每个线程都有一个起始地址,这个起始地址就是线程id,把这个线程id传给其他线程,就可以获取该线程的属性信息。
每个线程都拥有自己的栈结构,主线程用的是地址空间的栈。
深刻理解Linux多线程(重点)
当一个进程创建子进程时,需要创建PCB、进程地址空间、页表等。是非常独立的。一个进程内可以有多个线程,那么线程是怎么创建的呢?
- 给一个进程创建线程时,只会创建一个PCB,这个PCB还是会指向这个进程,进程地址空间内部有代码区,每个线程指向代码区中不同的代码区域,一个线程对应一个函数代码,这样一个线程就是一个执行流。这就是为什么线程是进程内部的一个执行流。
- 线程执行粒度更细因为可以执行进程中不同的代码,控制粒度更细。
- 调度成本更低因为PCB中存放进程地址空间的地址,当CPU切换PCB时,发现不用切换加载进程地址空间以及页表等一系列操作。但是最重要还是不用切换cache(cache是一个集成在CPU里面的硬件,也叫做高速缓存器,当我们访问内存中的代码的时候,会预先加载一部分代码到cache中,减少IO,提升效率,这个也叫做局部性原理。CPU切换的线程如果是同一个进程,cache不用切换数据,使得切换成本更低)。
- 之前谈到进程是CPU的基本调度单位,因为之前谈论的进程都是一个执行流,一个进程只有一个PCB,而多线程这里,一个进程有多个PCB,也就是多个执行流,对于CPU来说其实调度切换的是进程还是线程,CPU并不知道,也并不重要,CPU只需要可以通过PCB访问进程地址空间,通过页表映射到内存就可以了。
但是并不是所有的操作系统都是这样设计多线程的,这种是Linux下的多线程,而windows的里面线程和进程是不同的,Windows下的线程叫做TCB(线程控制块),而线程是进程的一个执行流,必须遵守执行粒度更细,调度成本更低,所以Windows的设计是比较复杂的。Windows里面是有真线程的,Linux下则是用进程的方案去模拟线程,所以Linux没有真正意义上的线程,都叫做轻量级进程(LWP)。Linux对比Windows复用代码结构,更简单,好维护,效率更高。
一个进程里面有多个执行流,有一个执行流是主执行流。每个进程都有一个pid,一个进程里面有多个执行流,每个执行流的pid当然都是一样的,但是每个执行流都有一个LWP是不一样的,CPU根据LWP来进行基本的调度。切换执行流,如果pid不变证明还是一个进程,不需要切换地址空间、cache等操作,如果pid变了,证明不是同一个进程了。
代码证明多线程有多个LWP,有同一个pid
void *run_thread1(void *args){
while(true)
{
cout<<"我是执行流1:"<<*((int*)args)<<endl;
sleep(1);
}
}
void *run_thread2(void *args){
while(true)
{
cout<<"我是执行流2:"<<*((int*)args)<<endl;
sleep(1);
}
}
void *run_thread3(void *args){
while(true)
{
cout<<"我是执行流3:"<<*((int*)args)<<endl;
sleep(1);
}
}
int main(){
pthread_t t1,t2,t3;
int th1=1;
pthread_create(&t1,nullptr,run_thread1,&th1);
int th2=2;
pthread_create(&t2,nullptr,run_thread2,&th2);
int th3=3;
pthread_create(&t3,nullptr,run_thread3,&th3);
while(true)
{
cout<<"我是主执行流"<<endl;
sleep(1);
}
return 0;
}
执行结果
这段代码使用了一下多线程,证明一个线程有一个LWP。主执行流的LWP和进程的pid是相同的,所以之前学习进程说的CPU根据pid调度进程也是对的。
这就是Linux下的多线程,后续会更新互斥和同步的文章,多多支持。