深度剖析操作系统核心(第二节):从X86/ARM/MIPS处理器架构到虚拟内存、分段分页、Linux内存管理,再揭秘进程线程限制与优化秘籍,助你成为OS高手!
文章目录
- OS
- 进程和线程
- 进程
- 单进程创建的线程数
- 线程
- 线程概述
- 线程与进程的区别
- 线程的创建与管理
- 线程的栈空间
- 线程同步
- 线程安全
- 线程的限制与性能
- 线程模型
- 线程的应用场景
- 常见问题
- 总结
OS
进程和线程
进程
单进程创建的线程数
**一个进程最多可以创建多少个线程?**这个问题跟两个东西有关系:
- 进程的虚拟内存空间上限。因为创建一个线程,操作系统需要为其分配一个栈空间,如果线程数量越多,所需的栈空间就要越大,那么虚拟内存就会占用的越多
- 系统参数限制。虽然 Linux 并没有内核参数来控制单个进程创建的最大线程个数,但是有系统级别的参数来控制整个系统的最大线程个数
结论
- 32 位系统:用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程
- 64 位系统:用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制
① 在进程里创建一个线程需要消耗多少虚拟内存大小?
我们可以执行 ulimit -a 这条命令,查看进程创建线程时默认分配的栈空间大小,比如我这台服务器默认分配给线程的栈空间大小为 8M。
② 32位Linux系统
一个进程的虚拟空间是 4G,内核分走了1G,留给用户用的只有 3G。那么假设创建一个线程需要占用 10M 虚拟内存,总共有 3G 虚拟内存可以使用。于是可以算出,最多可以创建差不多 300 个(3G/10M)左右的线程。
如果想使得进程创建上千个线程,那么我们可以调整创建线程时分配的栈空间大小,比如调整为 512k:
$ ulimit -s 512
③ 64位Linux系统
测试服务器的配置:64位系统、2G 物理内存、单核 CPU。
64 位系统意味着用户空间的虚拟内存最大值是 128T,这个数值是很大的,如果按创建一个线程需占用 10M 栈空间的情况来算,那么理论上可以创建 128T/10M 个线程,也就是 1000多万个线程,有点魔幻。所以按 64 位系统的虚拟内存大小,理论上可以创建无数个线程。事实上,肯定创建不了那么多线程,除了虚拟内存的限制,还有系统的限制。
比如下面这三个内核参数的大小,都会影响创建线程的上限:
/proc/sys/kernel/threads-max
:表示系统支持的最大线程数,默认值是14553
/proc/sys/kernel/pid_max
:表示系统全局的 PID 号数值的限制,每一个进程或线程都有 ID,ID 的值超过这个数,进程或线程就会创建失败,默认值是32768
/proc/sys/vm/max_map_count
:表示限制一个进程可以拥有的VMA(虚拟内存区域)的数量,具体什么意思我也没搞清楚,反正如果它的值很小,也会导致创建线程失败,默认值是65530
在这台服务器跑了前面的程序,其结果如下:
可以看到,创建了 14374 个线程后,就无法在创建了,而且报错是因为资源的限制。前面我提到的 threads-max
内核参数,它是限制系统里最大线程数,默认值是 14553。我们可以运行那个测试线程数的程序后,看下当前系统的线程数是多少,可以通过 top -H
查看。
左上角的 Threads 的数量显示是 14553,与 threads-max
内核参数的值相同,所以我们可以认为是因为这个参数导致无法继续创建线程。那么,我们可以把 threads-max 参数设置成 99999
:
echo 99999 > /proc/sys/kernel/threads-max
设置完 threads-max 参数后,我们重新跑测试线程数的程序,运行后结果如下图:
可以看到,当进程创建了 32326 个线程后,就无法继续创建里,且报错是无法继续申请内存。此时的上限个数很接近 pid_max
内核参数的默认值(32768),那么我们可以尝试将这个参数设置为 99999:
echo 99999 > /proc/sys/kernel/pid_max
设置完 pid_max 参数后,继续跑测试线程数的程序,运行后结果创建线程的个数还是一样卡在了 32768 了。经过查阅资料发现,max_map_count
这个内核参数也是需要调大的,但是它的数值与最大线程数之间有什么关系,我也不太明白,只是知道它的值是会限制创建线程个数的上限。然后,我把 max_map_count 内核参数也设置成后 99999:
echo 99999 > /proc/sys/kernel/pid_max
继续跑测试线程数的程序,结果如下图:
当创建差不多 5 万个线程后,我的服务器就卡住不动了,CPU 都已经被占满了,毕竟这个是单核 CPU,所以现在是 CPU 的瓶颈了。
接下来,我们换个思路测试下,把创建线程时分配的栈空间调大,比如调大为 100M,在大就会创建线程失败。
ulimit -s 1024000
设置完后,跑测试线程的程序,其结果如下:
总共创建了 26390 个线程,然后就无法继续创建了,而且该进程的虚拟内存空间已经高达 25T,要知道这台服务器的物理内存才 2G。为什么物理内存只有 2G,进程的虚拟内存却可以使用 25T 呢?因为虚拟内存并不是全部都映射到物理内存的,程序是有局部性的特性,也就是某一个时间只会执行部分代码,所以只需要映射这部分程序就好。
你可以从上面那个 top 的截图看到,虽然进程虚拟空间很大,但是物理内存(RES)只有使用了 400M+。
线程
线程概述
线程(Thread)是进程中执行的最小单位,是操作系统进行资源分配和调度的基本实体。相比进程,线程更加轻量级,因为同一进程内的多个线程共享进程的虚拟地址空间(包括代码段、数据段、堆等),但每个线程拥有独立的栈空间和寄存器上下文。这种共享与独立并存的特性使得线程在多任务处理中具有高效性和灵活性。
线程的主要特点包括:
- 共享资源:同一进程内的线程共享进程的内存空间、文件描述符、信号处理等资源。
- 独立性:每个线程有自己的程序计数器(PC)、栈空间和线程控制块(TCB),用于保存线程状态和上下文。
- 并发执行:线程可以在多核处理器上并行执行,也可以在单核处理器上通过时间片轮转实现并发。
线程与进程的区别
-
资源分配:
- 进程是资源分配的基本单位,拥有独立的虚拟地址空间和系统资源(如内存、文件句柄等)。
- 线程是 CPU 调度的基本单位,共享进程的资源,仅拥有独立的栈和少量线程私有数据。
-
开销:
- 创建进程的开销较大,因为需要分配独立的虚拟地址空间和资源。
- 创建线程的开销较小,仅需分配栈空间和初始化线程控制块,上下文切换也更快。
-
通信:
- 进程间通信(IPC)需要通过管道、消息队列、共享内存等机制,效率较低。
- 线程间通信直接通过共享内存实现,效率高,但需要同步机制(如锁)避免数据竞争。
-
独立性:
- 进程间是完全独立的,一个进程崩溃不会影响其他进程。
- 线程间共享资源,一个线程的错误可能导致整个进程崩溃。
线程的创建与管理
在 Linux 系统中,线程的创建通常通过 POSIX 线程库(pthread)实现。以下是线程创建的主要步骤:
-
创建线程:
使用pthread_create
函数创建一个新线程,指定线程的入口函数、参数以及栈大小等。#include <pthread.h> int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);
thread
:存储新线程的 ID。attr
:线程属性,如栈大小、调度策略等。start_routine
:线程执行的函数。arg
:传递给线程函数的参数。
-
终止线程:
- 线程可以通过
pthread_exit
主动退出,或通过函数返回终止。 - 其他线程可以通过
pthread_cancel
取消目标线程。
- 线程可以通过
-
等待线程:
使用pthread_join
等待指定线程结束并回收资源。int pthread_join(pthread_t thread, void **retval);
-
线程属性:
可以通过pthread_attr_t
设置线程的属性,如栈大小(pthread_attr_setstacksize
)、分离状态(pthread_attr_setdetachstate
)等。
线程的栈空间
如前所述,线程的栈空间是线程私有的一部分,决定了线程能够使用的局部变量和函数调用上下文的大小。在 Linux 中,线程栈大小可以通过以下方式查看和调整:
-
查看默认栈大小:
ulimit -s
默认栈大小通常为 8MB(8192KB),可以通过
ulimit -s <size>
修改。 -
动态调整栈大小:
在创建线程时,可以通过pthread_attr_setstacksize
指定栈大小。例如:pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setstacksize(&attr, 512 * 1024); // 设置栈大小为 512KB pthread_create(&thread, &attr, start_routine, NULL);
-
栈大小对线程数量的影响:
- 栈大小直接影响进程可以创建的线程数量。栈越大,虚拟内存占用越多,可创建的线程数越少。
- 在 32 位系统中,虚拟地址空间为 3GB,若栈大小为 10MB,则最多创建约 300 个线程。
- 在 64 位系统中,虚拟地址空间为 128TB,理论上线程数量受系统参数(如
threads-max
、pid_max
、max_map_count
)和硬件性能限制,而非虚拟内存限制。
线程同步
由于同一进程内的线程共享内存资源,多个线程同时访问共享数据可能导致数据不一致或竞争条件。因此,线程同步机制是多线程编程中的关键。常见的线程同步方法包括:
-
互斥锁(Mutex):
- 使用
pthread_mutex_lock
和pthread_mutex_unlock
保护临界区,确保同一时间只有一个线程访问共享资源。 - 示例:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_lock(&mutex); // 访问共享资源 pthread_mutex_unlock(&mutex);
- 使用
-
条件变量(Condition Variable):
- 用于线程间的协作,允许线程等待某个条件成立。常与互斥锁配合使用。
- 示例:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_lock(&mutex); while (condition_not_met) {pthread_cond_wait(&cond, &mutex); } pthread_mutex_unlock(&mutex);
-
信号量(Semaphore):
- 用于控制多个线程对有限资源的访问,适用于生产者-消费者模型。
- 示例:
#include <semaphore.h> sem_t sem; sem_init(&sem, 0, 1); // 初始化信号量,初始值为 1 sem_wait(&sem); // 等待信号量 // 访问资源 sem_post(&sem); // 释放信号量
-
读写锁(Read-Write Lock):
- 允许多个线程同时读取共享资源,但写操作独占资源,适合读多写少的场景。
- 示例:
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER; pthread_rwlock_rdlock(&rwlock); // 读锁 // 读取共享资源 pthread_rwlock_unlock(&rwlock); pthread_rwlock_wrlock(&rwlock); // 写锁 // 修改共享资源 pthread_rwlock_unlock(&rwlock);
线程安全
线程安全是指在多线程环境中,代码能够正确处理共享数据,保证结果的正确性和一致性。以下是一些实现线程安全的关键点:
-
避免共享状态:
- 尽量使用局部变量或线程私有数据,减少共享资源的使用。
- 使用线程局部存储(Thread-Local Storage,TLS)保存线程独有的数据:
__thread int thread_local_var; // 线程局部变量
-
使用同步机制:
- 通过互斥锁、信号量等机制保护共享资源。
-
原子操作:
- 使用原子操作(如
__sync_fetch_and_add
或 C11 的<stdatomic.h>
)实现无锁编程,减少锁的开销。 - 示例:
#include <stdatomic.h> atomic_int counter = 0; atomic_fetch_add(&counter, 1); // 原子递增
- 使用原子操作(如
-
不可变对象:
- 使用不可变对象(如 C 中的
const
数据)避免数据被修改。
- 使用不可变对象(如 C 中的
-
线程安全库:
- 使用线程安全的库函数(如
strtok_r
替代strtok
),避免非线程安全函数导致的未定义行为。
- 使用线程安全的库函数(如
线程的限制与性能
线程的数量和性能受到以下因素的限制:
-
虚拟内存限制:
- 如前所述,32 位系统中虚拟内存为 3GB,栈大小决定线程数量上限。
- 64 位系统中虚拟内存几乎无限制,但受系统参数和硬件性能约束。
-
系统参数限制:
threads-max
:系统最大线程数,默认约为 14553(视系统配置)。pid_max
:最大 PID 数,默认 32768,限制进程和线程总数。max_map_count
:限制虚拟内存区域数量,默认 65530,影响线程创建。
-
硬件性能:
- CPU 核心数决定并行线程的执行效率。单核 CPU 通过时间片轮转实现并发,多核 CPU 支持真正的并行。
- 内存带宽和缓存命中率影响线程切换和数据访问效率。
-
上下文切换开销:
- 线程切换需要保存和恢复寄存器、栈指针等,频繁切换会降低性能。
- 优化策略包括减少线程数量、避免过度同步和使用协程(如 libco)。
线程模型
线程模型描述了用户线程(User Thread)和内核线程(Kernel Thread)之间的映射关系,常见的线程模型包括:
-
一对一模型:
- 每个用户线程对应一个内核线程,Linux 使用的就是这种模型(通过 NPTL 实现)。
- 优点:线程调度由内核管理,性能高,支持多核并行。
- 缺点:创建和切换线程的开销较大。
-
多对一模型:
- 多个用户线程映射到一个内核线程,用户线程在用户态调度。
- 优点:用户态调度开销小,适合轻量级任务。
- 缺点:一个用户线程阻塞会导致整个进程阻塞,无法利用多核。
-
多对多模型:
- 多个用户线程映射到多个内核线程,结合了前两者的优点。
- 优点:灵活性高,既能利用多核又能减少内核线程开销。
- 缺点:实现复杂,调度开销较高。
Linux 通过 NPTL(Native POSIX Thread Library)实现了高效的一对一模型,使得线程创建和管理的性能接近进程。
线程的应用场景
线程在以下场景中广泛应用:
-
并发处理:
- Web 服务器(如 Nginx、Apache)使用多线程处理多个客户端请求。
- 数据库系统利用线程并行执行查询。
-
任务分解:
- 视频编码、图像处理等计算密集型任务通过线程分配到多核 CPU 提高效率。
-
异步 I/O:
- 使用线程处理异步 I/O 操作(如文件读写、网络请求),避免主线程阻塞。
-
实时系统:
- 嵌入式系统中,线程用于处理实时任务,如传感器数据采集和控制。
常见问题
-
线程创建失败的原因?
- 虚拟内存不足(栈空间分配失败)。
- 超过系统参数限制(如
threads-max
、pid_max
)。 - 物理内存不足或 CPU 负载过高。
-
如何优化线程性能?
- 减少线程数量,优先使用线程池复用线程。
- 调整栈大小,减少虚拟内存占用。
- 使用高效的同步机制(如读写锁、原子操作)。
- 根据任务特性选择合适的线程模型。
-
线程与协程的区别?
- 线程由内核调度,切换开销较大,支持多核并行。
- 协程(Coroutine)由用户态调度,切换开销小,但通常单线程运行,适合 I/O 密集型任务。
- 示例库:libco、Go 的 goroutine。
总结
线程是实现并发和高效任务处理的核心机制,通过共享进程资源实现轻量级调度。Linux 通过 NPTL 提供高效的线程支持,结合分页管理和多级页表优化内存使用。线程同步、栈大小调整和系统参数配置是多线程编程的关键点。在实际应用中,需根据任务特性权衡线程数量、同步机制和硬件资源,以实现最佳性能。