第二部分:并发编程
2.1 进程与线程
2.1.1 进程
2.1.1.1 进程的基本概念
进程是操作系统中,执行中的程序的实例。它包括了程序代码、数据、堆栈、寄存器等,操作系统为每个进程分配独立的地址空间。
- 作用:进程的主要作用在于实现并行执行,允许多个程序同时运行。
- 特点:
- 独立的地址空间:每个进程都有其独立的内存空间,这提高了安全性但同时也带来了更高的开销。
- 调度单位:进程是操作系统调度的基本单位,通过进程调度来实现多任务。
2.1.1.2 进程的优缺点
-
优点:
- 可靠性高:由于每个进程有独立的地址空间,一个进程的崩溃不会影响到其他进程。
- 资源隔离:进程之间的资源是隔离的,进程间的资源不直接共享,可以避免数据竞争与相互干扰的问题。
-
缺点:
- 资源浪费:进程的创建和上下文切换开销较大,尤其是在频繁进行进程切换时。
- 通信复杂:进程间的通信(IPC)相对复杂,需要使用管道、消息队列、共享内存等机制。
2.1.1.3 进程创建与管理
fork
:用于创建一个新的子进程。子进程是父进程的拷贝,只是进程ID不同。exec
:用于替换当前进程的地址空间,用新程序的内容替换当前进程内容。wait
:用于等待子进程的结束。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h> // 需要引入wait函数所在的头文件 [1]
int main() {
pid_t pid = fork(); // 创建子进程 [2]
if (pid == 0) {
// 子进程执行的代码
execlp("/bin/ls", "ls", NULL); // 子进程执行新程序 [3]
} else if (pid > 0) {
// 父进程执行的代码
wait(NULL); // 等待子进程结束 [4]
printf("Child process finished\n");
} else {
// fork失败
perror("fork failed"); // 输出错误信息 [5]
}
return 0;
}
- [1] 引入头文件:
sys/wait.h
用于声明wait()
函数,这个函数允许父进程等待子进程结束。 - [2] 创建子进程:
fork()
创建一个子进程。子进程是父进程的副本,并返回两次:在父进程中返回子进程的PID,在子进程中返回0
。如果失败,它返回-1
并设置errno
。 - [3] 子进程执行新程序:
execlp()
用于在子进程中运行一个新程序。在这个例子中,子进程尝试执行ls
命令以列出目录内容。execlp()
替代子进程的执行上下文,使其执行指定的命令。如果成功,将不会返回。 - [4] 等待子进程结束:
wait(NULL)
阻塞父进程,直到任一子进程退出或有信号中断。NULL
表示不关心子进程的退出状态。此操作防止父进程早于子进程结束。 - [5] 输出错误信息:如果
fork()
失败,将调用perror()
输出错误信息,提示fork failed
以帮助调试。
2.1.1.4 进程间通信(IPC)机制
进程间通信(IPC)是指在不同进程之间传递数据的技术与方法。常见的IPC机制包括:
- 管道(Pipe):用于单向或双向通信,常用于父子进程间通信。
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h> // 确保包含wait函数定义
int main() {
int fd[2];
pipe(fd); // 创建管道 [1]
pid_t pid = fork(); // 创建子进程 [2]
if (pid == 0) {
// 子进程写
close(fd[0]); // 关闭读端 [3]
write(fd[1], "hello", 5); // 向管道写入数据 [4]
} else if (pid > 0) {
// 父进程读
close(fd[1]); // 关闭写端 [5]
char buffer[10];
read(fd[0], buffer, 5); // 从管道读取数据 [6]
buffer[5] = '\0'; // 添加字符串结束符 [7]
printf("Received from child: %s\n", buffer);
wait(NULL); // 等待子进程结束 [8]
}
return 0;
}
-
[1] 创建管道:
pipe(fd)
创建一个管道,fd
是一个包含两个文件描述符的数组:fd[0]
用于读数据,fd[1]
用于写数据。 -
[2] 创建子进程:
fork()
创建一个新的子进程,返回值是子进程的PID。在子进程中,返回值是0;在父进程中,返回子进程的PID。 -
[3] 关闭子进程的读端:子进程不需要读取管道,因此关闭读取端
fd[0]
。 -
[4] 向管道写入数据:子进程通过
fd[1]
写入"hello"字符至管道。这里写入5字节数据,但不包括字符串结尾的\0
。 -
[5] 关闭父进程的写端:父进程不需要向管道写数据,因此关闭写入端
fd[1]
。 -
[6] 从管道读取数据:父进程从
fd[0]
读取5字节数据到buffer
,确保读取的是刚好写入的字节数。 -
[7] 添加字符串结束符:在从管道读取的数据末尾添加
\0
,用于表示字符串结束。 -
[8] 等待子进程结束:
wait(NULL)
使父进程等待子进程结束,防止产生僵尸进程。 -
消息队列:允许多个进程通过消息队列发送和接收消息。
-
共享内存:允许多个进程共享一块物理内存段,比消息队列效率更高,但需要同步机制。
-
信号:用于通知进程特定的异步事件。
该程序演示了如何使用 POSIX 消息队列在进程间传递数据。消息队列是一种强大的进程间通信(IPC)机制,用于在不同进程之间传递数据。
#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/msg.h>
// 定义消息缓冲区结构
struct msg_buffer {
long msg_type; // 消息类型 [1]
char msg_text[100]; // 消息内容 [2]
};
int main() {
key_t key;
int msgid;
struct msg_buffer message;
// 创建消息队列
key = ftok("progfile", 65); // 生成唯一键 [3]
msgid = msgget(key, 0666 | IPC_CREAT); // 创建消息队列或获取已存在的 [4]
message.msg_type = 1; // 指定消息类型为1 [5]
printf("Write Data : ");
fgets(message.msg_text, 100, stdin); // 从标准输入读取消息 [6]
// 发送消息
msgsnd(msgid, &message, sizeof(message), 0); // 发送消息到消息队列 [7]
printf("Data send is : %s \n", message.msg_text);
return 0;
}
- [1] 消息类型:
long msg_type
指定消息的类型。当接收消息时,可以根据类型来选择性接收不同的消息。 - [2] 消息内容:
char msg_text[100]
定义了可以最大容纳100个字符的消息内容。 - [3] 生成唯一键:
ftok()
函数通过指定的文件路径和整数生成唯一键,用于识别消息队列。在项目开发中,合理选择用于生成键的路径和整数是关键。 - [4] 创建消息队列或获取已存在的:
msgget()
使用生成的键创建新队列或访问现有队列。0666 | IPC_CREAT
是标志,表示创建权限和如果不存在则创建队列。 - [5] 指定消息类型为1:通过指定
message.msg_type = 1;
来设置发送消息的类型。 - [6] 从标准输入读取消息:
fgets()
从标准输入读取消息文本并存储在msg_text
中,最大读取100个字符。 - [7] 发送消息到消息队列:
msgsnd()
通过其参数将消息发送到消息队列。参数包括消息队列标识符,指向消息缓冲区的指针,消息的大小,以及发送消息时的标志。
这种结构化的分析,有助于理解每一行代码的逻辑与异步通信机制。
通过这些示例代码和详细解释,你可以对进程的创建、管理以及进程间通信(IPC)的基本机制有更全面的了解。这些知识是并发编程的基础,也是实现多任务操作的关键。
2.1.2 线程
2.1.2.1 线程的基本概念
线程是进程中的一个执行单元,也可被称为“轻量级进程”。一个进程可以包含多个线程,这些线程共享进程的资源,如内存空间、打开的文件等,但每个线程有自己的栈空间和寄存器。线程使并行执行任务成为可能。
- 作用:实现并发执行,提升程序的性能和响应能力。
- 特点:
- 共享内存:线程共享进程的地址空间,使线程间通信更加高效。
- 独立执行:每个线程独立执行,具有自己的栈和程序计数器。
2.1.2.2 线程与进程的区别
-
内存空间:
- 进程:不同进程拥有相互独立的内存空间。
- 线程:同一进程中的线程共享内存空间。
-
通信方式:
- 进程:进程间通信需要依赖IPC机制(如管道、消息队列、共享内存等),效率较低。
- 线程:线程间通信通过共享内存直接实现,效率较高。
-
创建开销:
- 进程:进程创建和销毁的开销较大,需要进行内存和资源的分配和回收。
- 线程:线程创建和销毁的开销较小,资源共享使管理更加轻量。
-
并发与并行:
- 进程:实现并行度高,可运行在多个处理器上。
- 线程:实现并行度有限,多线程仍需共享单个处理器的时间片。
2.1.2.3 线程创建与管理
POSIX线程库(Pthreads)是用于多线程编程的标准库,主要提供了一组用于创建和管理线程的API函数。
- 线程创建:
#include <pthread.h>
// 定义线程执行函数
void* thread_function(void* arg) {
// 线程执行的代码 [1]
return NULL;
}
int main() {
pthread_t thread; // 线程标识符 [2]
pthread_create(&thread, NULL, thread_function, NULL); // 创建线程 [3]
pthread_join(thread, NULL); // 等待线程完成 [4]
return 0;
}
-
[1] 线程执行的代码:这是由新创建的线程执行的代码块,当前例子中不执行任何特定功能,只是返回
NULL
表示线程的结束。在实际应用中,这部分代码将执行特定的任务。 -
[2] 线程标识符:
pthread_t thread
是用于存储线程标识符的变量,它用于标识唯一的线程实例。 -
[3] 创建线程:
pthread_create(&thread, NULL, thread_function, NULL)
是用来创建一个新线程的函数。- 第一个参数
&thread
是指针,用于接收新创建线程的标识符。 - 第二个参数是线程属性,一般设置为
NULL
,表示默认属性。 - 第三个参数是指向线程执行函数的指针,这里是
thread_function
。 - 第四个参数是传递给线程的参数,这里为
NULL
,表示不需要传递参数。
- 第一个参数
-
[4] 等待线程完成:
pthread_join(thread, NULL)
是主线程阻塞等待新创建的线程thread
完成执行。- 第一个参数是目标线程的标识符。
- 第二个参数用于在目标线程完成后收集返回值,当前设为
NULL
,表示不关心返回值。
本程序演示了如何使用 POSIX 线程(pthread
)库在 C 语言中进行多线程开发。通过 pthread_create
函数创建线程,并用 pthread_join
确保主线程等待子线程的执行完成。通过这种方式,可以实现并发执行的能力。
- 线程管理:
- 线程ID:每个线程有唯一的线程标识符(pthread_t)。
- 线程退出:使用
pthread_exit
函数手动退出线程。
2.1.2.4 线程的优缺点
-
优点:
- 并发执行:可以同时处理多个任务,提高程序性能。
- 资源共享:线程间可以方便地共享全局变量和静态变量,通信成本低。
- 响应性高:适合需要频繁响应用户请求的情况,如多线程服务器。
-
缺点:
- 复杂性高:多线程编程难度大,需要处理线程同步和死锁问题。
- 资源竞争:线程共享内存空间,易导致资源争用和数据竞争问题。
- 调试困难:多线程程序的调试和测试比单线程程序要复杂很多。
2.1.2.5 线程属性与控制
线程的属性可以在创建线程时进行设置,以满足特定场景的需求。这些属性包括分离状态、调度策略和优先级等。
-
分离状态:
-
分离线程:使用
pthread_detach
将线程设置为分离状态,分离线程在结束后会自动释放资源。 -
非分离线程:需要使用
pthread_join
显式地等待线程结束并释放资源。pthread_t thread; // 线程标识符 [1] pthread_create(&thread, NULL, thread_function, NULL); // 创建线程 [2] pthread_detach(thread); // 分离线程 [3]
-
[1] 线程标识符:
pthread_t thread
用于保存新创建线程的标识符,你可以通过该变量管理和操作线程,例如终止、等待等。 -
[2] 创建线程:
pthread_create
函数:用于创建一个新的线程。- 参数详解:
&thread
:指向线程标识符的指针,该标识符将被函数赋值为新线程的ID。NULL
:用于指定线程属性(使用默认属性时为NULL
)。thread_function
:指向线程函数的指针,该函数规定了新线程的起始执行点。NULL
:传递给线程函数的参数,若无参数传递则为NULL
。
-
[3] 分离线程:
pthread_detach
函数:将线程状态设为分离状态。- 分离状态的特点:
- 分离状态的线程在结束时自动释放其占用的资源。
- 主线程无需调用
pthread_join
等待线程结束。 - 对于生命周期简单且无需主线程获取其返回结果的线程,使用分离状态是高效的做法。
-
-
-
调度策略:
- SCHED_FIFO:先到先得调度,适用于实时性要求高的任务。
- SCHED_RR:轮转调度,按时间片轮转执行线程。
- 默认策略:依赖于系统实现,通常为SCHED_OTHER。
int pthread_setschedparam(pthread_t thread, int policy, const struct sched_param *param);
-
thread
:线程标识符。 -
policy
:线程调度策略。常见的策略有SCHED_FIFO
、SCHED_RR
和SCHED_OTHER
。 -
param
:指向包含调度参数的sched_param
结构体的指针。-
调度策略:
SCHED_FIFO
:先进先出的实时调度策略。SCHED_RR
:时间片轮转的实时调度策略。SCHED_OTHER
:通常系统的缺省调度策略。
-
关键代码解释:
struct sched_param param; param.sched_priority = 1; // 设置线程优先级 [1] pthread_setschedparam(thread, SCHED_FIFO, ¶m); // 设置调度策略和参数 [2]
- [1] 设置线程优先级:通过指定
sched_param
结构体中的sched_priority
字段来定义线程的优先级。这一数值决定了线程在调度时被优先执行的程度。优先级数值越大,优先级越高。 - [2] 设置调度策略和参数:
pthread_setschedparam()
函数被调用,以SCHED_FIFO
策略和之前设置的调度参数应用到指定线程上。
- [1] 设置线程优先级:通过指定
-
注意事项:
- 调度策略和优先级的调整通常需要根权限或特定系统权限,否则可能会失败。
- 不同操作系统对调度策略和优先级的支持可能有所不同。
- 必须确保线程创建之后、使用
pthread_setschedparam
之前,线程对象thread
是有效的。
-
-
优先级:
- 设置线程的优先级,以决定线程的调度顺序。优先级高的线程会优先执行。
- 调用
pthread_setschedparam
或通过pthread_attr_setschedparam
在创建线程时设置。
通过合理使用线程属性,可以更高效地控制线程的行为,提升程序的性能和响应能力。
代码示例
以下代码创建一个打印数字的多线程程序,通过设置不同属性演示线程的控制和管理。
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
// 函数 print_numbers:线程执行的函数
void* print_numbers(void* arg) {
int num = *((int*)arg); // 将 void* 类型的参数转换为 int* 然后解引用 [1]
for (int i = 0; i < 5; ++i) {
printf("Thread %d: %d\n", num, i); // 打印线程编号及计数 [2]
sleep(1); // 每次循环暂停1秒 [3]
}
return NULL;
}
int main() {
pthread_t threads[2]; // 创建两个线程的标识符 [4]
int nums[2] = {1, 2}; // 用于传递给线程的参数 [5]
// 创建线程
for (int i = 0; i < 2; ++i) {
pthread_create(&threads[i], NULL, print_numbers, &nums[i]); // 创建线程 [6]
}
// 等待线程结束
for (int i = 0; i < 2; ++i) {
pthread_join(threads[i], NULL); // 主线程等待子线程完成 [7]
}
return 0;
}
-
[1] 转换类型和解引用:函数
print_numbers
接受一个void*
类型的参数。在这里,将其转换为int*
并进行解引用,以获取具体的整数值。 -
[2] 打印信息:每个线程在循环中打印自己的编号和计数器的当前值。
-
[3] 暂停执行:使用
sleep(1)
让线程在每次迭代之间暂停1秒。 -
[4] 线程标识符数组:
pthread_t threads[2]
用于储存2个线程的标识符。 -
[5] 参数数组:
int nums[2] = {1, 2};
用于保存将被传递给每个线程的参数。 -
[6] 线程创建:
pthread_create()
用于创建新线程。传入threads[i]
作为线程标识符储存位置,print_numbers
作为线程执行函数,&nums[i]
作为参数传递给线程。 -
[7] 线程同步:
pthread_join()
用于阻塞主线程,直到指定的线程完成执行。这保证了程序在子线程执行完毕后才终止。
通过以上讲解和示例代码,希望你能更好地理解C语言中的线程概念以及线程的创建和管理方法。在实际应用中,可以根据需要设置合适的线程属性,以实现高效的并发编程。
2.1.3 多线程编程基础
C语言的多线程编程通过POSIX线程(Pthreads)库来实现。理解多线程编程的基础概念及其实现方式是掌握并发编程的关键。
2.1.3.1 线程函数与线程标识符
-
线程函数:线程函数是由线程运行的函数。它的原型为
void *(*start_routine)(void *)
,即,一个返回类型为void *
,参数为void *
类型的函数。线程函数通常在调用pthread_create
时作为参数传递。 -
线程标识符:线程标识符(
pthread_t
)是用来表示线程的类型。在创建线程及其他线程操作时,都会用到线程标识符。
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
// 线程函数定义
void *thread_function(void *arg) {
int *num = (int *)arg; // 将参数转换为整数指针并解引用 [1]
printf("Thread is running with argument: %d\n", *num); // 输出参数值
return NULL;
}
int main() {
pthread_t thread; // 定义线程变量 [2]
int thread_arg = 10;
// 创建线程并启动执行
if (pthread_create(&thread, NULL, thread_function, (void *)&thread_arg) != 0) {
perror("pthread_create failed"); // 输出错误信息并退出 [3]
exit(EXIT_FAILURE);
}
// 等待线程结束
pthread_join(thread, NULL); // 阻塞主线程,直到thread所指的线程结束 [4]
return 0;
}
- [1] 参数转换:在线程函数
thread_function
中,传入的arg
被转换为int *
类型指针,以访问传递的参数值。 - [2] 线程变量:
pthread_t thread
声明了一个线程对象,用于标识和操控线程。 - [3] 错误检查:
pthread_create
函数可能会失败,通过检查其返回值,使用perror
函数输出错误信息并使用exit(EXIT_FAILURE)
退出程序。 - [4] 线程同步:
pthread_join
函数使主线程等待指定线程的完成,这可以确保在线程的生命周期结束后再继续进行后续的程序操作。
知识点补充:
- 线程:线程是程序中的执行单元。每个线程都有自己的栈和寄存器状态,同时多个线程共享程序的全局变量和堆内存。
pthread_create
:用于创建新线程,并且需要传入线程对象指针、线程属性、线程函数和线程参数。成功返回0,失败返回错误码。pthread_join
:用于阻塞调用线程,直到目标线程结束。一旦一个线程调用pthread_join
,该线程将进入等待状态,直到指定pthread_t
线程终止,并可选择性地接收线程的返回状态。
2.1.3.2 线程的生命周期
线程的生命周期从其创建到终止,通常包括以下五个状态:
- 初始状态:线程由主线程创建,使用
pthread_create
函数。创建后进入可运行状态。 - 可运行状态:线程处于可调度运行状态,等待操作系统的调度。
- 运行状态:线程实际正在CPU上运行。
- 阻塞状态:线程因等待资源(如锁、I/O操作)进入等待状态。
- 终止状态:线程完成任务或被取消,进入终止状态。线程结束后可以使用
pthread_join
回收其资源。
2.1.3.3 线程退出与取消
- 线程退出:线程可以通过以下几种方式退出:
- 正常返回:线程函数返回,引发线程退出。
pthread_exit
:显式调用pthread_exit
退出线程。
void *thread_function(void *arg) {
// 程序此处可以加入一些操作...
pthread_exit(NULL); // 线程奶出
return NULL; // 这一段代码是不会执行到的
}
- 线程取消:一个线程可以请求取消另一个线程,使用
pthread_cancel
。
pthread_cancel(thread_id);
需要注意的是,线程取消可能不立即生效,需要目标线程检查其取消点(如阻塞I/O操作)。
2.1.3.4 线程特定数据
在多线程编程中,有时需要每个线程有自己独立的一份数据。POSIX线程库提供了线程特定数据机制来实现这一需求。
- 线程特定数据管理函数:
pthread_key_create
:创建一个线程特定数据的键。pthread_setspecific
:为特定键设置线程特定数据。pthread_getspecific
:获取特定键的线程特定数据。pthread_key_delete
:删除线程特定数据的键。
在多线程编程中,有时我们希望为每个线程存储特定的数据,线程局部存储(TLS)就是为每个线程单独维护这样的数据空间的机制。在C中,通过POSIX线程(pthread)库提供的接口,我们可以实现这样的功能。
#include <pthread.h>
#include <stdio.h>
pthread_key_t key; // 定义线程特定数据键 [1]
void destructor(void *value) {
free(value); // 在线程退出时销毁数据 [4]
}
void *thread_function(void *arg) {
int *thread_data = (int *)malloc(sizeof(int)); // 为线程特定数据动态分配内存 [2]
*thread_data = (int)(size_t)arg;
pthread_setspecific(key, thread_data); // 设置线程特定数据 [5]
printf("Thread %d specific data: %d\n", *thread_data, *(int *)pthread_getspecific(key)); // 获取并打印线程特定数据 [6]
return NULL;
}
int main() {
pthread_t threads[2];
pthread_key_create(&key, destructor); // 创建线程特定数据键,并设置析构函数 [3]
for (int i = 0; i < 2; i++) {
pthread_create(&threads[i], NULL, thread_function, (void *)(size_t)i); // 创建线程 [7]
}
for (int i = 0; i < 2; i++) {
pthread_join(threads[i], NULL); // 等待所有线程完成 [8]
}
pthread_key_delete(key); // 删除线程特定数据键 [9]
return 0;
}
- [1] 定义线程特定数据键:
pthread_key_t key;
用于标识线程特定数据。 - [2] 动态分配线程特定数据:为每个线程分配一个整数的内存空间,这样每个线程都有其独立的数据。
- [3] 创建线程特定数据键:
pthread_key_create(&key, destructor);
初始化一个key,并指定一个析构函数destructor
,此函数在线程退出时自动调用来释放与该key相关联的数据。 - [4] 析构函数:
destructor
用于在线程结束时清理分配的内存,防止内存泄漏。 - [5] 设置线程特定数据:
pthread_setspecific
将thread_data
与当前线程的key关联。 - [6] 获取线程特定数据:通过
pthread_getspecific
获取当前线程的特定数据并打印。 - [7] 创建线程:使用
pthread_create
来创建多个线程,并将线程索引(如0、1)传递给线程函数。 - [8] 等待线程完成:
pthread_join
确保主程序等待所有线程执行完成后再继续。 - [9] 删除线程特定数据键:通过
pthread_key_delete
删除key,不再需要对其进行数据关联操作。
这种机制在开发需要线程安全且使用线程局部变量的应用时非常有用,例如线程池中处理不同的工作任务,需要为每个线程储存其独立的状态或数据时。
通过以上步骤,线程特定数据可以为每个线程提供独立的数据存储,避免了线程间数据干扰的问题。
这部分内容涵盖了多线程编程的基础知识点,包括线程函数和标识符、线程生命周期、线程退出与取消以及线程特定数据的管理。希望这些详细的解释和代码示例能够帮助你更好地理解和运用C语言的多线程编程技术。