文章目录
一、进程和线程
1、进程(Process):
- 进程是操作系统进行资源分配和调度的基本单位。每个进程都拥有独立的内存空间,包括代码段、数据段、堆和栈,以及操作系统分配给它的其他资源,如文件描述符、信号处理器等。进程之间是相互隔离的,一个进程的崩溃通常不会直接影响到其他进程。
在下面的多线程示例中,整个运行的程序可以被视为一个进程,它包含了多个并发执行的线程。
2、线程(Thread):
- 线程是进程内的执行单元,是CPU调度的基本单位。线程共享所属进程的内存空间和资源,包括全局变量和堆空间。每个线程拥有自己的程序计数器、栈(用于存储函数调用和局部变量)和一组寄存器,这使得线程能够独立执行任务。
在多线程打印数字的示例中,每个负责打印数字的执行路径就是一个线程
。这些线程共享相同的代码和全局变量,但各自拥有独立的执行序列,可以同时执行。
!!!简单理解,一个“程序”在被执行时成为一个“进程”,而该进程内部可以创建多个“线程”来并行或并发地执行程序中的代码片段(这些代码片段可以是函数、方法或任意可执行的指令序列)。每个线程都维护了自己的执行上下文(如栈),使得它们可以独立地执行并与其他线程共享进程资源。
二、进程栈和线程栈
了解进程和线程,那进程栈和线程栈就很容易理解了。栈可以理解为内存的意思,即一个程序在被执行时成为一个进程时,就会拥有一块内存,用于储函数调用时的局部变量、函数参数、返回地址等信息。
1、进程栈:
- 当一个程序被操作系统加载并执行时,会为其分配一块内存作为进程的地址空间,其中就包括了进程栈。这块栈内存用来存储该进程内所有线程共享的全局变量之外的数据,比如函数调用时的局部变量、函数参数、返回地址以及保存的寄存器值等。由于每个进程是独立的,因此每个进程有其独立的进程栈,以保证数据隔离。
2、线程栈:
- 在线程模型中,每个线程除了共享所属进程的地址空间外,还会拥有自己的线程栈。线程栈同样用于存储函数调用时的局部变量、函数参数和返回地址等,但是它是线程私有的,每个线程的栈空间互不影响。这意味着即使多个线程同时执行相同函数,它们在各自的线程栈上保存的局部变量也不会混淆,从而保证了线程间的数据隔离性和并发安全。
简而言之,栈是一种特殊的内存区域,它通过“后进先出”(Last In First Out, LIFO)的方式管理数据,对于维持函数调用的顺序和状态至关重要。进程栈服务于整个进程,而线程栈服务于进程内的每一个线程。
三、代码示例
1、单线程示例:简单计算器
这是一个简单的单线程C语言程序,模拟一个基础的计算器,只包含一个主函数来执行一系列数学运算。
#include <stdio.h>
// 计算两个数的和
int add(int a, int b)
{
return a + b;
}
// 计算两个数的差
int subtract(int a, int b)
{
return a - b;
}
int main()
{
int num1 = 10;
int num2 = 5;
printf("Sum: %d\n", add(num1, num2));
printf("Difference: %d\n", subtract(num1, num2));
return 0;
}
2、多线程示例:并发打印数字
这是一个使用POSIX线程库(pthread)的C语言多线程示例。这个程序创建了两个线程,每个线程负责打印一系列数字,展示了如何在多线程环境中工作。
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
// 线程函数原型声明
void* print_numbers(void* arg);
int main() {
pthread_t thread1, thread2;
// 创建第一个线程
if (pthread_create(&thread1, NULL, print_numbers, (void*)"Thread 1 ")) {
perror("Error creating thread 1");
exit(1);
}
// 创建第二个线程
if (pthread_create(&thread2, NULL, print_numbers, (void*)"Thread 2 ")) {
perror("Error creating thread 2");
exit(1);
}
// 等待两个线程结束
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("Both threads finished execution.\n");
return 0;
}
// 线程函数,打印一系列数字
void* print_numbers(void* arg) {
char* threadName = (char*)arg;
for (int i = 0; i < 5; ++i) {
printf("%sNumber: %d\n", threadName, i);
// 模拟延时,让线程交错打印效果更明显
sleep(2);
}
pthread_exit(NULL);
}
在这个多线程示例中,print_numbers
函数作为线程的入口点,接收一个字符串参数arg
来标识是哪个线程在执行。两个线程并发执行这个函数,打印带有线程名称的数字序列,展示了多线程并发工作的基本概念。
3、多线程代码运行结果
通过在每个线程的打印操作后加入sleep(2)
,人为地引入了时间延迟,这使得两个线程在执行时产生了明显的时间间隔,从而让用户能更直观地观察到多线程并发的效果。每个线程按顺序打印数字,但由于线程调度的不确定性,线程1和线程2的打印输出可能会交错出现,显示了并发执行的特点。
由于是多线程并发执行,每个线程的这些操作都是独立且异步发生的。因此,线程栈的变化体现在:
- 并发性:线程1和线程2会在各自的栈上独立进行上述操作,互不影响。这意味着,即使在某个时间点两个线程都在执行
printf
调用,它们使用的栈空间是分开的,不会相互干扰。 - 交替执行:由于线程调度的不确定性,线程1和线程2可能会交替执行
print_numbers
函数。在任一线程暂停执行以等待I/O(如sleep(1)
)或因线程调度而让出CPU时,其栈状态会被保存,下次该线程恢复执行时,从上次中断的地方继续,栈的状态也会相应恢复。
4、GDB调试结果分析
从GDB调试会话记录中,可以看到以下步骤和分析:
- 设置断点:在
print_numbers
函数处设置了一个断点(Breakpoint 1)。 - 启动程序:运行程序后,GDB报告了线程调试启用,并且显示了新创建的线程信息。程序暂停在
print_numbers
函数的第一个线程(线程2,LWP 2093)上。 - 查看调用栈(Backtrace):通过
bt
命令查看当前线程的调用栈,确认程序暂停在预期的位置,即print_numbers
函数内部。 - 查看线程信息:使用
info threads
查看所有线程的状态,显示了主线程(LWP 2090)、线程2(LWP 2093)和线程3(LWP 2094)的信息。 - 继续执行:第一次使用
c
(continue)命令后,程序继续执行,GDB切换到线程3(LWP 2094),该线程也命中了print_numbers
函数的断点。 - 再次查看调用栈和线程信息:第二次查看调用栈和线程信息时,线程2的状态变为执行
munmap
函数,这是正常的线程生命周期管理操作,可能意味着线程2正在清理资源或准备退出。 - 继续执行至结束:第二次使用
c
命令继续执行后,程序打印出线程交替执行的结果,每个线程按照预定逻辑打印了0到4的数字,随后两个工作线程(线程2和线程3)相继退出,最终程序正常结束。
四、关于GDB调试工具的应用
在代码调试中,有几种常用的调试工具可以帮助你观察程序运行时线程栈的变化,最常用的工具之一是GDB(GNU Debugger)。下面是使用GDB观察线程栈动态变化的基本步骤:
1、 安装GDB
如果还没有安装GDB,可以通过终端命令安装:
sudo apt-get update
sudo apt-get install gdb
2、 编译程序
需要使用-g
选项编译你的程序,以便包含调试信息:
gcc -g your_program.c -o your_program -lpthread
3、启动GDB
这里假设你的多线程程序源代码保存在your_program.c
中,并且使用了POSIX线程库
打开终端,使用GDB加载你的程序:
gdb ./your_program
4、设置断点
在你想要观察线程栈变化的代码位置设置断点,比如在print_numbers
函数开始处:
break print_numbers
5、运行程序
让程序在GDB中运行:
run
6、观察线程栈
当程序在断点处停止时,你可以使用以下命令来查看当前线程的栈信息:
bt # 显示当前线程的完整调用栈
info threads # 查看所有线程的状态
7、动态观察
可以通过连续执行continue
(或简写为c
)命令让程序继续运行至下一个断点,然后再次使用bt
命令检查线程栈的变化,以此来动态观察线程栈随程序执行的演变。