前言:
(一)线程的概念
(二)线程的理解
(三)示例
(四)线程优缺点
线程的优点
线程的缺点
(五)线程和进程的切换
1.线程的切换
2.进程的切换
(六)线程的控制
1.POSIX 线程库
2.线程的创建
3.线程的查看
4.线程的等待
5.线程的退出
6.线程的取消
前言:
在讲线程之前,我们需要回顾之前的进程:
程序运行后,操作系统创建对应的PCB数据结构,然后生成虚拟地址空间,分配内存资源,相关的代码和数据加载到物理内存中,并通过页表映射关系和虚拟地址空间建立联系。
如此一看,创建一个进程,需要创建PCB、开辟地址空间等,操作系统需要做很多工作,所以创建一个进程所需的成本是很高的,而想要在一个程序中使用多执行流,如果创建多个进程的话,那么程序运行的成本就太高了。
我们仔细观察进程的地址空间,这里面的资源都是被一个task_struct的结构体所享有的,页表也是其所独有的;如果我们创建一个“子进程”,但让它不去创建新的地址空间和页表映射,而是和父进程PCB共享地址空间和页表,程序运行时,父进程就可以将部分代码给“子进程”,这样父进程运行的同时,“子进程”也可以运行,而父进程能创建一个这样的“子进程”,也能创建很多个:
我们新创建出来的“子进程”,它们的执行粒度要比“父进程”要细一些,因为“父进程”需要执行全部代码,而“子进程”只需执行部分代码。 正式的讲,我们把这些“子进程”,统称为线程!
所以在Linux中,线程在进程“内部”执行,就是线程在进程的地址空间内运行,那么为什么线程要在进程的地址空间中运行呢?首先,任何执行流要执行,都要有资源。而地址空间是进程的资源窗口,但线程有地址空间吗?没有,因为它没有建立和内存的联系,所以线程需要依附于进程!
在CPU角度,CPU需要知道要执行的到底是进程还是线程吗?不需要,因为它的核心工作就是执行代码,而进程和线程都是执行流,都能执行代码。
所以我们创建的多个新的task_struct都能被CPU调度,而不像之前只能进程被调度,并且创建线程的成本远比进程低,所以大大提高了CPU的效率。这些能被CPU调度、却没被操作系统分配资源的task_struct就叫做线程!
(一)线程的概念
有了上面的引入,我们重新定义线程和进程。
如何看待今天的进程呢?进程 = 内核数据结构+代码和数据
对于之前的进程来说,它是内核中只有一个执行流的进程;而现在的进程是内核中有多个执行流的进程。所以进程是承担系统资源的基本实体。
所以我们可以得出
- 线程是CPU调度的最小单位。
- 进程是调度和分配的基本单位。
(二)线程的理解
由上面的图,我们可以看出:
每个进程都有自己独立的地址空间,地址空间是指进程可访问的内存范围,包括了数据段、代码段、堆、栈等。
因为进程之间的地址空间都是独立的,所以不同进程之间的数据都是相互隔绝的,一个进程无法直接访问另一个进程的地址空间,进程间通信需要通过特定的机制来完成,比如共享内存、消息队列、信号量等。
而在多线程的情况下,每个线程并不具有独立的地址空间。线程是进程内的执行单元。多个线程共享同一个进程的地址空间,所有线程都可以访问同一个进程的地址空间,共享相同的全局变量和静态变量。
由于线程共享进程的地址空间,因此线程之间可以更方便的进行数据共享和通信。但是也会带来一些潜在的问题,比如线程之间可能会互相干扰,需要通过同步机制来确保线程之间数据访问的正确性。
为什么线程的执行粒度比进程的低?
- 资源共享和切换开销较小:线程是在同一个进程内创建的轻量级执行单元,它们共享进程的地址空间和资源。因此,线程之间的切换开销通常比进程之间的切换开销小,这使得线程更适合处理需要频繁切换和共享数据的任务。
- 更快的创建和销毁速度:线程的创建和销毁通常比进程快得多,因为线程之间的资源共享较多,创建线程时只需要复制一份线程控制块和栈空间即可。相比之下,进程的创建和销毁需要复制整个地址空间,资源消耗更大。
- 更好的并发性和并行性:由于线程共享进程的地址空间,线程之间的通信和同步更加方便快捷。在多核处理器上,多线程程序可以更好地利用多核资源进行并行计算,提高系统的整体性能。
- 更灵活的设计和实现:多线程编程相对于多进程编程来说,通常更灵活、更容易实现。线程之间可以直接共享内存,不需要通过进程间通信的机制来进行数据传递和同步,这简化了程序的设计和调试过程。
为什么线程调度的成本比进程的低?
- 线程共享资源:线程是进程的子集,多个线程共享同一进程的资源,包括内存空间、文件描述符等。因此,在调度线程时,不需要切换和分配额外的资源,只需切换线程的上下文即可。
- 线程切换开销小:线程的切换只需要切换线程的上下文,而进程的切换需要保存和恢复整个进程的状态,包括地址空间、文件描述符等。
- 线程调度更灵活:由于线程共享进程的资源,操作系统可以更灵活的调度线程,不需要额外的资源分配和回收操作,提高了调度的效率和性能。
(三)示例
#include <iostream>
#include <pthread.h>
#include <unistd.h>int gcnt = 100;
void *ThreadRoutine(void *arg)
{const char *threadname = (const char *)arg;while (true){std::cout << "I am a new thread,pid:" << getpid() << " gcnt:" << gcnt << " &gcnt:" << &gcnt << std::endl;gcnt--;sleep(1);}
}int main()
{// 创建线程之前,已经有进程了pthread_t tid1;pthread_create(&tid1, nullptr, ThreadRoutine, (void *)"thread 1");while (true){std::cout << "I am main thread,pid:" << getpid() << ",gcnt:" << gcnt << " &gcnt:" << &gcnt << std::endl;sleep(1);}return 0;
}
运行程序:
从上面我们可以看出来,主进程和线程两个执行流它们的pid是相同的,所调取的全局变量gcnt的物理地址、大小也是相同的。
由此可以看出,线程和进程或者说线程和线程之间并不是父子进程的关系,它们所用的地址空间也是同一个。
使用下面的命令我们可以查出当前正在运行的线程/进程的信息
while :; do ps -aL | head -1 && ps -aL | grep testThread ; sleep 1;done
可以看到,运行程序后两个线程的PID是相同的,但是LWP的值是不同的,那么LWP是什么呢?
LWP(LightWeight Process,轻量级进程):在Linux系统中,线程被实现为轻量级进程,每个线程都有自己的线程控制块(TCB),用于保存线程的上下文信息。LWP之间可以共享进程的资源,如地址空间、文件描述符等,同时可以独立地进行调度和执行。
在这里的LWP指的是线程的编号。
(四)线程优缺点
线程的优点
- 创建一个新线程的代价要比创建一个新进程小的多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
线程的缺点
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因为共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高
编写与调试一个多线程程序比单线程程序困难的多。
那为什么一个线程引发的错误要让整个进程来承担呢?
- 共享地址空间:在POSIX线程中,同一进程内的所有线程共享相同的地址空间,这意味着它们可以访问相同的内存区域,包括全局变量、堆和栈。因此,如果一个线程在共享内存区域中写入了错误的数据或者越界访问量某个数据结构,这个错误将会影响到其他进程以及进程的运行状态。
- 信号和异常处理:在Linux中,当一个线程引发了一个信号或者异常,操作系统会将这个信号或异常发送给整个进程,而不是仅仅发送给产生错误的线程。这是因为在POSIX线程中,默认情况下所有线程共享相同的信号处理器设置。因此,如果一个线程引发了一个信号,整个进程都会接收到该信号,这可能导致整个进程的状态被改变或者终止。
- 资源共享:进程中的所有线程共享各种资源,包括打开的文件、网络连接、进程ID等。如果一个线程错误的关闭了一个文件描述符,或者引发了其他类似的资源管理错误,这会影响到整个进程的其他线程。
- 取消线程:在POSIX线程中,可以使用pthread_cancel()函数来取消一个线程。如果一个线程被取消,那么整个进程的其他线程也可能会受到影响,具体取决于取消动作的方式和取消点的设置。
(五)线程和进程的切换
1.线程的切换
线程的切换是指从一个线程切换到另一个线程执行的过程。在多线程程序中,操作系统负责管理线程的调度和切换,确保多个线程能够合理地共享CPU资源。
线程的切换通常发生在以下几种情况下:
- 时间片耗尽:操作系统会为每个线程分配一定的时间片(即CPU时间),当线程执行的时间片耗尽时,操作系统会进行线程切换,将CPU时间分配给其他线程执行。这样可以保证每个线程都有机会执行。
- 阻塞或等待:当一个进程因为等待某些事件发生而被阻塞时,操作系统会进程线程切换,将CPU事件分配给其他可执行的线程。当等待的事件发生后,被阻塞的线程会被唤醒,重新进入就绪状态,等待再次被调度执行。
- 线程调用了阻塞系统调用:某些系统调用(例如I/O操作)会导致线程进入阻塞状态,等待系统完成相应的操作。在这种情况下,操作系统会进行线程切换,执行其他可执行的线程,直到系统调用完成并唤醒线程。
- 显示调用线程调度函数:在某些情况下,程序员可以显式地调用线程调度函数,例如
pthread_yield()
函数,来请求操作系统进行线程切换。
线程切换的具体实现由操作系统负责,通常包括以下步骤:
保存上下文:保存当前正在执行线程的上下文信息,包括程序计数器、寄存器状态、栈指针等。
选择下一个线程:根据调度算法选择下一个要执行的线程,可能会根据线程的优先级、调度策略等进行选择。
恢复上下文:恢复被选中线程的上下文信息,并将控制权转移到该线程的执行代码处
线程切换是操作系统中一项重要的工作,其效率和性能对系统的整体性能有很大影响。因此,操作系统的线程调度算法和线程切换的实现通常会进行优化,以提高系统的性能和响应能力。
2.进程的切换
进程的切换是指从一个进程切换到另一个进程执行的过程。
进程切换通常涉及更多的开销和复杂性,因为不同进程之间的地址空间是独立的,需要进行上下文的完全切换。
进程的切换通常发生在以下几种情况下:
时间片耗尽:每个进程被分配一个时间片来执行,当时间片耗尽时,操作系统会强制进行进程切换,将 CPU 时间分配给其他就绪态的进程。这样可以保证每个进程都有机会执行,并避免了某个进程长时间占用 CPU 而导致其他进程无法执行的情况。
阻塞或等待:当一个进程需要等待某些事件发生(如 I/O 操作完成、信号等)而被阻塞时,操作系统会进行进程切换,将 CPU 时间分配给其他就绪态的进程。当等待的事件发生后,被阻塞的进程会被唤醒,重新进入就绪态,等待再次被调度执行。
进程调用了阻塞系统调用:某些系统调用(例如等待 I/O 完成的系统调用)会导致进程进入阻塞状态,等待系统完成相应的操作。在这种情况下,操作系统会进行进程切换,执行其他可执行的进程,直到系统调用完成并唤醒进程。
优先级调度:如果有更高优先级的进程需要执行,操作系统可能会进行进程切换,将 CPU 时间分配给优先级更高的进程,以提高系统对高优先级任务的响应速度。
抢占式调度:在支持抢占式调度的系统中,高优先级进程可以在任何时候抢占低优先级进程的 CPU 时间,导致进程切换。这样可以确保高优先级任务的及时执行。
以下是进程切换的一般过程:
保存上下文:操作系统会保存当前进程的所有寄存器状态、程序计数器和其他必要的执行环境信息,以便稍后恢复。
选择下一个进程:操作系统根据调度算法选择下一个要执行的进程。调度算法可能基于进程的优先级、调度策略等进行选择。
保存进程状态:操作系统会保存下一个进程的状态信息。这包括进程的寄存器状态、程序计数器以及其他与进程执行相关的状态。
切换地址空间:由于不同进程具有不同的地址空间,因此在切换进程时,操作系统需要将当前的地址空间切换为下一个进程的地址空间。这通常涉及修改内存管理单元(MMU)或处理器的地址转换表。
恢复上下文:操作系统会恢复被选中进程的上下文信息,并将控制权转移到该进程的执行代码处。
进程切换通常比线程切换开销更大,因为进程拥有独立的地址空间,需要额外的资源来管理和维护。此外,进程切换还可能涉及到内核态和用户态之间的切换,因此开销更大。然而,与线程切换相比,进程切换的优点在于进程之间的隔离性更好,更不容易相互影响。
(六)线程的控制
1.POSIX 线程库
POSIX线程库( pthreads)是一种标准的线程库,定义了一组用于创建和管理线程的API。POSIX是Portable Operating System Interface的缩写,是一个IEEE标准,定义了操作系统接口的标准,包括线程、进程、信号、文件系统等。
2.线程的创建
#include <pthread.h>int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);
作用:
用于创建一个进程。
参数:
- pthread_t *thread:一个指向pthread_t 类型的指针,用来存储新创建线程的标识符。
- const pthread_attr_t *attr:一个指向pthread_attr_t类型的指针,用于指定新线程的属性。通常可以传入NULL,表示使用默认属性。
- void *(*start_routine)(void*):一个函数指针,指向新线程将要执行的函数。函数的返回值和参数必须都是void *类型。
- void *arg:传递给start_routine函数的参数。
返回值:
函数执行成功,将会创建一个新的线程,并将其标识符存储在thread指针指向的内存当中,返回0;如果执行失败,将返回错误编号。
3.线程的查看
查看线程既可以在Linux终端中输入ps命令进程查看,也可以在程序中调用系统函数pthread_self()来查看。
#include <pthread.h>pthread_t pthread_self(void);
作用:
在线程中获取自身的线程ID,以便于进程一些线程相关的操作,比如打印线程信息、线程同步等。
返回值:
返回线程自身的线程ID。
4.线程的等待
在线程编程中,有时候需要等待一个线程完成其任务,然后才能继续执行其他操作。在 POSIX 线程库中,可以使用 pthread_join()
函数来等待一个线程结束。
#include <pthread.h>int pthread_join(pthread_t thread, void **retval);
作用:
调用pthread_join函数将会阻塞当前进程,直到指定的线程结束为止。当目标线程结束时,当前线程会继续执行,并且获取目标线程的返回值。
参数:
- pthread_t thread:要等待的进程ID。
- void **retval:用于存储目标进程的返回值。如果不关心目标线程返回值,可以传入NULL
返回值:
- 如果pthread_join函数成功返回,即等待目标线程结束,并成功获取了目标线程的返回值,返回值为0。
- 如果pthread_join函数返回一个非零值,表示等待目标线程结束时出现了错误,此时可以根据返回值判断具体的错误类型。
5.线程的退出
在进程当中,我们学习了用exit来结束一个进程,而对于线程来说也同样适用,只不过对线程使用exit函数会导致整个进程一起退出。
另外,还可以利用return来让线程退出。
我们还可以先程序中调用pthread_exit()函数来让单个线程退出,它不会让整个进程一起退出。
#include <pthread.h>void pthread_exit(void *retval);
作用:
在某个线程中调用该函数,可以终止当前线程的执行,并返回指定的值。
参数:
- void *retval:可选参数,表示线程的退出状态,类型为void*。该参数可以用于向调用pthread_join函数的线程传递一个退出状态值。如果不需要向其他线程传递退出状态,可以将retval设为NULL。
6.线程的取消
线程的取消是指在某个线程执行过程中,另一个线程主动终止目标线程的执行。POSIX线程库提供了pthread_cancel()函数来取消一个线程的执行。
#include <pthread.h>int pthread_cancel(pthread_t thread);
作用:
取消目标线程的执行。
参数:
- pthread_t thread:thread_t类型,表示要取消执行的进程ID。
返回值:
如果成功发送了取消请求给目标进程,返回0;
如果发送请求时出现了错误,返回非零值。
以上就是对线程的初理解啦,写了7K字,觉得写的不错的小伙伴可以点个赞~