【Linux系统编程】第四十一弹---线程深度解析:从地址空间到多线程实践
✨个人主页: 熬夜学编程的小林
💗系列专栏: 【C语言详解】 【数据结构详解】【C++详解】【Linux系统编程】
目录
1、Linux线程概念
2、重谈地址空间
2.1、Linux地址空间的定义
2.2、Linux地址空间的类型
2.3、Linux地址空间的分布
2.4、Linux地址空间的映射与转换
2.5、Linux地址空间的管理
3、重新定义 进程 和 线程
4、线程的优缺点
4.1、优点
4.2、缺点
5、见一见多线程
1、Linux线程概念
什么是线程?
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”
- 一切进程至少都有一个执行线程
- 线程在进程内部运行,本质是在进程地址空间内运行
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
2、重谈地址空间
2.1、Linux地址空间的定义
在Linux系统中,每个进程都有自己独立的地址空间,这些地址空间在物理内存中被映射为一组页框,并由操作系统进行管理。这些地址空间包含了进程的代码、数据、堆栈等,确保了进程的独立性和安全性。
2.2、Linux地址空间的类型
Linux地址空间主要分为以下几类:
- 内核空间:操作系统内核使用的地址空间。在32位系统中,内核空间通常位于高地址区域,大小约为1GB(在Linux中)或2GB(在Windows中)。内核空间用于存储内核代码、数据结构和内核管理的各种资源。
- 用户空间:用户进程使用的地址空间。在32位系统中,用户空间通常位于低地址区域,大小约为3GB(在Linux中)或2GB(在Windows中)。用户空间包含了进程的代码段、数据段、堆栈段等。
2.3、Linux地址空间的分布
以32位系统为例,Linux进程地址空间的分布通常如下:
- 栈(Stack):用于维护函数调用的上下文,通常位于用户空间的最高地址处。栈是后进先出(LIFO)的数据结构,用于存储局部变量、函数调用参数和返回地址等。
- 动态链接库映射区:用于映射装载的动态链接库。如果可执行文件依赖于其他共享库,系统将在特定地址区域分配空间,并将共享库载入到该空间。
- 堆(Heap):用于容纳应用程序动态分配的内存区域。当使用
malloc
或new
等函数分配内存时,得到的内存来自堆。堆通常位于栈的下方(低地址方向),并可以动态增长。 - 可执行文件映像:存储着可执行文件在内存里的映像,包括代码段(只读)、数据段(可读可写)等。这些段在装载时被映射到虚拟地址空间的不同区域。
- 保留区:对内存中受到保护而禁止访问的内存区域的总称。这些区域通常用于防止非法访问和越界操作。
2.4、Linux地址空间的映射与转换
- 虚拟地址与物理地址:在Linux中,进程使用的地址是虚拟地址,而非物理地址。虚拟地址通过页表映射到物理内存,实现了进程与物理内存的解耦合和内存保护。
- 页表:页表是虚拟地址到物理地址映射的工具。当进程访问一个虚拟地址时,操作系统会查找页表,将虚拟地址转换为实际的物理地址。如果页表项不存在或无效,将触发缺页中断,操作系统会加载相应的页到物理内存中,并更新页表。
- 内存保护:通过页表的访问控制位,操作系统可以实现内存保护。例如,可以将某个内存区域标记为只读,当进程尝试写入该区域时,将触发页表错误,从而防止非法写操作。
2.5、Linux地址空间的管理
在Linux内核中,地址空间通过struct address_space
结构表示。每个内存映射都有一个相应的地址空间结构,其中包含了与该内存映射相关的所有信息。地址空间结构定义在头文件中,并包含了许多成员变量,如映射的页帧、页表、页表操作函数等。
此外,Linux内核还提供了一系列函数和机制来管理地址空间,如内存分配与释放、内存映射与取消映射、内存保护等。这些函数和机制确保了进程的稳定性和安全性,并提高了系统的性能和效率。
虚拟地址本质是一个资源!!!
3、重新定义 进程 和 线程
进程 = 内核数据结构 + 进程的代码和数据
进程的定义 -- 内核观点:
- 承担分配系统资源的实体
线程的定义:
- 在进程内部运行,是CPU调度的基本单位
Linux中没有真正的线程,使用的是进程模拟线程,Windows有真正的线程!!!
问题1:已经有了多进程,为什么要有多线程?
1、进程创建成本很高!创建线程成本很低! --- 启动
2、线程调度成本低! -- 运行
3、删除一个线程直接死亡 (进程可能出现僵尸进程)
问题2:不同系统对于进程和线程实现都不同,为什么OS的课本只有一本?
- 无论是哪种操作系统,进程和线程的管理都涉及到创建、调度、同步、通信和销毁等基本操作。
- 这些操作的基本原理和算法在各类操作系统中都是相似的,只是具体实现方式可能有所不同。
如何管理线程?
先描述在组织!!!
struct tcb
{
// 线程的pid,优先级,状态,上下文,链接属性......
}
我们为什么要单独设计一个数据结构,来表示线程(进程执行流)呢?能不能复用pcb,使用pcb统一管理执行流呢?
答案是可以使用pcb统一管理进程执行流,Linux也就是这样做的!!!但是Windows提供了真实的线程控制块!!!
4、线程的优缺点
4.1、优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
4.2、缺点
- 性能损失
- 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低
- 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制
- 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高
- 编写与调试一个多线程程序比单线程程序困难得多
5、见一见多线程
pthread_create()
pthread_create -- 创建一个新线程
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
Compile and link with -pthread. -- 使用-pthread编译链接
参数:
pthread_t *thread:这是一个指向 pthread_t 类型变量的指针,
用于存储新创建的线程的标识符。当函数成功返回时,这个变量将包含新线程的线程 ID。
const pthread_attr_t *attr:这是一个指向线程属性对象的指针,
用于指定新线程的属性,如栈大小、调度策略等。如果设置为 NULL,则使用默认属性。
void *(*start_routine) (void *):这是一个指向函数的指针,
该函数将作为新线程的执行起点。这个函数必须接受一个 void* 类型的参数,
并返回一个 void* 类型的结果。这个参数和返回值可以用来传递数据或状态信息。
void *arg:这是传递给 start_routine 函数的参数。它的类型和内容由程序员定义,
可以是任何需要传递给新线程的数据。
返回值:
成功时,pthread_create() 返回 0。
失败时,返回一个非零的错误码,常见的错误码包括:
EAGAIN:系统资源不足,无法创建新线程。
EINVAL:传入的参数无效,如 thread 指针为 NULL,或 attr 指定的属性无效。
ENOMEM:内存不足,无法为新线程分配资源。
EBUSY:资源暂时不可用,如系统已达到最大线程数。
代码演示:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
void* threadStart(void* args)
{
while(true)
{
std::cout << "new thread running..." << ",pid: " << getpid() << std::endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,threadStart,(void*)"thread-new");
// 主线程
while(true)
{
std::cout << "main thread running..." << ",pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
运行结果
同一个PID,两个函数同时运行!!!