12.3 基于线程的并发编程
到目前为止,我们已经看到了两种创建并发逻辑流的方法。
在第一种方法中,我们为每个流使用了单独的进程。内核会自动调度每个进程. 而每个进程有它自己的私有地址空间,这使得流共享数据很困难。
在第二种方法中,我们创建自己的逻辑流,并利用 I/O 多路复用来显式地调度流。因为只有一个进程,所有的流共享整个地址空间。本节介绍第三种方法——基于线程,它是这两种方法的混合。
线程(thread)就是运行在进程上下文中的逻辑流。在本书里迄今为止,程序都是由每个进程中一个线程组成的。但是现代系统也允许我们编写一个进程里同时运行多个线程的程序。线程由内核自动调度。
每个线程都有它自己的线程上下文(thread context),包括一个唯一的整数线程 ID(Thread ID,TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间。
基于线程的逻辑流结合了基于进程和基于 I/O 多路复用的流的特性。同进程一样,线程由内核自动调度,并且内核通过一个整数 ID 来识别线程。同基于 I/O 多路复用的流一样,多个线程运行在单一进程的上下文中,因此共享这个进程虚拟地址空间的所有内容,包括它的代码、数据、堆、共享库和打开的文件。
12.3.1 线程执行模型
多线程的执行模型在某些方面和多进程的执行模型是相似的。思考图 12-12 中的示例。每个进程开始生命周期时都是单一线程,这个线程称为主线程(main thread)。在某一时刻,主线程创建一个对等线程(peer thread),从这个时间点开始,两个线程就并发地运行。最后,因为主线程执行一个慢速系统调用,例如 read 或者 sleep,或者因为被系统的间隔计时器中断,控制就会通过上下文切换传递到对等线程。对等线程会执行一段时间,然后控制传递回主线程,依次类推。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ku8knUt9-1622542085838)(assert/image-20210531150941218.png)]
在一些重要的方面,线程执行是不同于进程的。因为一个线程的上下文要比一个进程的上下文小得多,线程的上下文切换要比进程的上下文切换快得多。另一个不同就是线程不像进程那样,不是按照严格的父子层次来组织的。和一个进程相关的线程组成一个对等(线程)池,独立于其他线程创建的线程。主线程和其他线程的区别仅在于它总是进程中第一个运行的线程。对等(线程)池概念的主要影响是,**一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。**另外,每个对等线程都能读写相同的共享数据。
wow,这里很重要,线程是没有地位区别的,都是同等地位!!!
12.3.2 Posix 线程

——注意,线程函数返回值必须是void*,参数也必须是void *
**Posix 线程(Pthreads)**是在 C 程序中处理线程的一个标准接口。它最早出现在 1995 年,而且在所有的 Linux 系统上都可用。Pthreads 定义了大约 60 个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。
图 12-13 展示了一个简单的 Pthreads 程序。
#include "csapp.h"
void *thread(void *vargp);
int main()
{
pthread_t tid;
Pthread_create(&tid, NULL, thread, NULL);
Pthread_join(tid, NULL);
exit(0);
}
void *thread(void *vargp) /* Thread routine */
{
printf("Hello, world!\n");
return NULL;
}
//图 12-13 hello.c:使用 Pthreads 的 “Hello, world!” 程序
主线程创建一个对等线程,然后等待它的终止。对等线程输岀 “Hello, world!\n” 并且终止。当主线程检测到对等线程终止后,它就通过调用 exit 终止该进程。这是我们看到的第一个线程化的程序,所以让我们仔细地解析它。线程的代码和本地数据被封装在一个线程例程(thread routine)中。正如第二行里的原型所示,每个线程例程都以一个通用指针作为输入,并返回一个通用指针。如果想传递多个参数给线程例程,那么你应该将参数放到一个结构中,并传递一个指向该结构的指针。相似地,如果想要线程例程返回多个参数,你可以返回一个指向一个结构的指针。
第 4 行标出了主线程代码的开始。主线程声明了一个本地变量 tid,可以用来存放对等线程的 ID(第 6 行)。主线程通过调用 pthread_create 函数创建一个新的对等线程(第 7 行)。当对 pthread_create 的调用返回时,主线程和新创建的对等线程同时运行,并且 tid 包含新线程的 ID。通过在第 8 行调用 pthread_join,主线程等待对等线程终止。最后,主线程调用 exit(第 9 行),终止当时运行在这个进程中的所有线程(在这个示例中就只有主线程)。
第 12 ~ 16 行定义了对等线程的例程。它只打印一个字符串,然后就通过执行第 15 行中的 return 语句来终止对等线程。
注意:线程在这个函数return时就结束了.

可以看到,我们的main线程执行main的函数,在return 0后exit,而新线程从函数return就terminate终止了.
我们如果在中间加一句bye,如下


可以看到,在create之后,我们的新线程打印Hello,wolrd后就return了,然后就终止了,main线程打印一个bye,然后用join等新线程终止,才继续执行。
12.3.3 创建线程
线程通过调用 pthread_create 函数来创建其他线程
#include <pthread.h>
typedef void *(func)(void *);
int pthread_create(pthread_t *tid, pthread_attr_t *attr,func *f, void *arg);
// 若成功则返回 0,若出错则为非零。
pthread_create 函数创建一个新的线程,并带着一个输入变量 arg,在新线程的上下文中运行线程例程 f。能用 attr 参数来改变新创建线程的默认属性。改变这些属性已超出我们学习的范围,在我们的示例中,总是用一个为 NULL 的参数来调用 pthread_create 函数。
返回值:若成功则返回 0,若出错则为非零。
当 pthread_create 返回时,参数 tid 包含新创建线程的 ID。新线程可以通过调用 pthread_self 函数来获得它自己的线程 ID。
#include <pthread.h>
pthread_t pthread_self(void);
// 返回调用者的线程 ID。
12.3.4 终止线程
——贯彻整个的原则是,我们的所有线程都终止,进程才会终止
一个线程是以下列方式之一来终止的:🦅
-
当顶层的线程例程返回时,线程会隐式地终止。
-
通过调用 pthread_exit 函数,线程会显式地终止。如果主线程调用 pthread_exit,它会等待所有其他对等线程终止,然后再终止主线程和整个进程,返回值为 thread_return。
-
对等线程调用exit函数,该函数终止该进程的所有线程并终止该进程
-
pthread_cancel(pid),对等线程调用该函数,终止线程ID为tid的线程
1.线程例程return时,线程会隐式终止
🥇 先来看第一点——顶层线程返回时,线程会隐式地终止(这个意思不是说main线程返回,子线程会隐式的终止,而是说thread函数return时,自动终止这个线程,return自动终止这个子线程是显然的,我们来测试,main线程返回时不会使子线程终止)
#include <csapp.c>
#include <sys/syscall.h>
void *thread(void *vargp);
pthread_t main_tid;
int main()
{
main_tid = pthread_self(); //全局变量tid=main线程
pthread_t tid;
Pthread_create(&tid, NULL, thread, NULL);
printf("Main Hello\n");
syscall(SYS_exit, 0);
printf("Main Bye\n");
}
void *thread(void *vargp) /* Thread routine */
{
printf("Hello, world!\n");
sleep(2);
int num = 0;
while (num < 10){
sleep(1);
printf("Thread spin\n");
num ++;
}
printf("Thread Bye\n");
}
结果:

我们可以看到,父线程__exit时,我们的子线程仍然在运行,并且thread return终止时,此时所有线程都终止了,那么进程就终止.
2.主线程调用pthread_exit会等待其他对等线程终止后,终止当前线程并终止整个进程,返回值为 thread_return
#include <pthread.h>
void pthread_exit(void *thread_return);
// 从不返回。
🥈第二点,主线程调用pthread_exit,它会等待所有其他对等线程终止,然后再终止这个线程和整个进程.
#include <csapp.c>
void *thread(void *vargp);
int main()
{
pthread_t tid;
Pthread_create(&tid, NULL, thread, NULL);
printf("Main Hello\n");
pthread_exit(0);
printf("Main Bye\n");
}
void *thread(void *vargp) /* Thread routine */
{
printf("Hello, world!\n");
while (1)
;
return NULL;
}
在这个例子中,由于子线程不会终止,所以主线程用pthread_exit来对等线程终止,会卡在那,如下图

再举一个例子,看看成功用pthread等待对等线程终止是什么效果.
#include <csapp.c>
void *thread(void *vargp);
int main()
{
pthread_t tid;
Pthread_create(&tid, NULL, thread, NULL);
Pthread_create(&tid, NULL, thread, NULL);
printf("Main Hello\n");
pthread_exit(0);
printf("Main Bye\n");
}
void *thread(void *vargp) /* Thread routine */
{
printf("Hello, world!\n");
sleep(2);
return NULL;
}

这里有三个知识:
- pthread_exit要等所有对等线程都终止了才会返回,所以两个Hello.World都会打印
- pthread_exit等对等线程都终止了以后,Main线程不会继续执行,而是执行退出,所以不会打印Main Bye
- 经测试,必须是主线程调用pthread_exit才有用.
3.某个线程调用exit函数,会终止所有线程以及这个进程(exit_group)
🥉某个对等线程调用 Linux 的 exit 函数,该函数终止进程以及所有与该进程相关的线程。
见下面的例子:
#include <csapp.c>
void *thread(void *vargp);
int main()
{
pthread_t tid;
Pthread_create(&tid, NULL, thread, NULL);
printf("Main Hello\n");
while (1)
;
}
void *thread(void *vargp) /* Thread routine */
{
printf("Hello, world!\n");
sleep(2);
exit(0);
}
可以看到,在子线程,我们调用exit(0),main线程是while(1)的循环的
结果如下:

可以看到,确实是exit了.
我们在jyy的笔记虚拟化——进程抽象中就说了这件事:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-khX67gHz-1622542085843)(assert/image-20210531192936006.png)]
(其中,__exit()其实是syscall(SYS_exit, 0)😉
我们的exit和_exit,都是调用exit_group(),不仅调用exit的该线程,还终止调用该进程的线程组的所有线程!!
我们如果把exit改成syscall(SYS_exit,0)的话,就不会终止所有线程,如下:

另外,我们main函数的return 0,是exit_group(),所以这就是为什么,我们main函数return 0时,子线程就自动终止,并且把整个进程终止啦
4.线程执行pthread_cancel(tid)函数来终止tid线程。
4️⃣另一个对等线程通过以当前线程 ID 作为参数调用 pthread_Cancel 函数来终止当前线程。
#include <pthread.h>
int pthread_cancel(pthread_t tid);
// 若成功则返回 0,若出错则为非零。
pthread_cancel是指定一个线程去终止他.
举一个例子吧:
#include <csapp.c>
void *thread(void *vargp);
pthread_t main_tid;
int main()
{
main_tid = pthread_self(); //全局变量tid=main线程
pthread_t tid;
Pthread_create(&tid, NULL, thread, NULL);
printf("Main Hello\n");
while (1){
sleep(1);
printf("Main spin\n");
}
printf("Main Bye\n");
}
void *thread(void *vargp) /* Thread routine */
{
printf("Hello, world!\n");
sleep(2);
pthread_cancel(main_tid);
while (1){
sleep(1);
printf("Thread spin\n");
}
printf("Thread Bye\n");
}

可以看到,在这里,我们的thread去pthread_cancle(main_tid)去终止main线程,确实成功终止了,然后我们的thread继续执行.
12.3.5 回收已终止线程的资源(pthread_join)
线程通过调用 pthread_join 函数等待其他线程终止。
#include <pthread.h>
int pthread_join(pthread_t tid, void **thread_return);
// 若成功则返回 0,若出错则为非零。
pthread_join 函数会阻塞,直到线程 tid 终止,将线程例程返回的通用 (void ) 指针赋值为 thread_return 指向的位置,然后回收已终止线程占用的所有内存资源*。
注意,和 Linux 的 wait 函数不同,pthread_join 函数只能等待一个指定的线程终止。没有办法让 pthread_wait 等待任意一个线程终止。这使得代码更加复杂,因为它迫使我们去使用其他一些不那么直观的机制来检测进程的终止。实际上,Stevens 在【110】中就很有说服力地论证了这是规范中的一个错误。
12.3.6 分离线程(pthread_detach)
在任何一个时间点上,线程是可结合的(joinable)或者是分离的(detached)。
一个可结合的线程能够被其他线程收回和杀死。在被其他线程回收之前,它的内存资源(例如栈)是不释放的。
相反,一个分离的线程是不能被其他线程回收或杀死的。它的内存资源在它终止时由系统自动释放。
默认情况下,线程被创建成可结合的。为了避免内存泄漏,每个可结合线程都应该要么被其他线程显式地收回,要么通过调用 pthread_detach 函数被分离。
#include <pthread.h>
int pthread_detach(pthread_t tid);
// 若成功则返回 0,若出错则为非零。
pthread_detach 函数分离一个可结合线程 tid。线程能够通过以 pthread_self() 参数的 pthread_detach 调用来分离它们自己。
尽管我们的一些例子会使用可结合线程,但是在现实程序中,有很好的理由要使用分离的线程。例如,一个高性能 Web 服务器可能在每次收到 Web 浏览器的连接请求时都创建一个新的对等线程。因为每个连接都是由一个单独的线程独立处理的,所以对于服务器而言,就很没有必要(实际上也不愿意)显式地等待每个对等线程终止。在这种情况下,每个对等线程都应该在它开始处理请求之前分离它自身,这样就能在它终止后回收它的内存资源了。
12.3.7 初始化线程(pthread_once)
在多线程环境中,有些事仅需要执行一次。通常当初始化应用程序时,可以比较容易地将其放在main函数中。但当你写一个库时,就不能在main里面初始化了,你可以用静态初始化,但使用一次初始化(pthread_once)会比较容易些。
pthread_once 函数允许你初始化与线程例程相关的状态。
#include <pthread.h>
pthread_once_t once_control = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *once_control,
void (*init_routine)(void));
// 总是返回 0。
once_control 变量是一个全局或者静态变量,总是被初始化为 PTHREAD_ONCE_INIT。(本函数使用初值为PTHREAD_ONCE_INIT的once_control变量保证init_routine()函数在本进程执行序列中仅执行一次。)
当你第一次用参数 once_control 调用 pthread_once 时,它调用 init_routine,这是一个没有输入参数、也不返回什么的函数。
接下来的以 once_control 为参数的 pthread_once 调用不做任何事情。无论何时,当你需要动态初始化多个线程共享的全局变量时,pthread_once 函数是很有用的。我们将在 12.5.5 节里看到一个示例。
(现在还没看懂啥意思,等学到后面再回头看吧)
12.3.8 基于线程的并发服务器
图 12-14 展示了基于线程的并发 echo 服务器的代码。
#include <csapp.c>
void echo(int connfd);
void *thread(void *vargp);
int main(int argc, char **argv)
{
int listenfd, *connfdp, port;
socklen_t clientlen = sizeof(struct sockaddr_in);
struct sockaddr_in clientaddr;
pthread_t tid;
if (argc != 2) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(0);
}
port = atoi(argv[1]); //接收argv[1]为端口号
listenfd = Open_listenfd(port); //在port端口上创建一个监听描述符
while (1) {
// 为了避免对等线程的赋值语句和主线程的accept语句间引入的竞争
connfdp = Malloc(sizeof(int));
*connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen); //fd
Pthread_create(&tid, NULL