0.目标完成情况
又摸了几天的鱼,终于完成了3天前的目标,再加了一点
- 牛客两道题,最好录个视频
- csapp收尾
- unp开始看
- 侯捷C++视频看一节
1. 学习回顾
1.1 线程内存模型
线程是在进程的上下文中运行的,虽然线程有自己的上下文。
进程的上下文内容包括:
- 代码
- 数据
- 堆
- 共享库
- 栈
- 程序计数器
- 通用目的寄存器值
- 打开文件表
当不涉及多线程的时候,其实进程当中就是一个线程,称为主线程。所谓多线程,就是这个主线程创建了对等线程,“对等”二字是为了强调这些线程和主线程之间不是“父子”关系,任何一个线程可以回收其他线程。
一个进程中的多个线程是各自有各自独立的线程上下文的。线程上下文包括:
- 栈
- 栈指针
- 程序计数器
- 通用目的寄存器的值
- 线程ID
可以看到,当涉及多线程的时候,线程是有自己的栈的,以及自己的通用目的寄存器值。
总的来说,多个线程之间共享同一进程的全部虚拟地址空间,包括代码、数据、堆和共享库,注意,虽然栈看起来是线程私有的,但是其实不同的线程是可以以某种方式直接访问其他线程的栈的,只不过通常这不是我们想要的。而寄存器的值则永远不是共享的。
由线程的内存模型可知,线程的上下文开销要更小,更加方便的共享同一进程内部的代码、数据、共享库以及打开文件表。因此线程的上下文切换的开销更小,而操作系统的调度单位正是线程。
1.2 线程池
在之前讨论的服务器中,每当有连接请求到达的时候,我们总是新创建一个线程,来执行任务。这个创建的过程比较耗时。我们可以提前创建好一些线程,然后让这些线程从一个缓冲区中读取已连接的描述符,这些线程就是消费者,而负责监听并返回已连接描述符的主线程则为生产者,利用这样的生产者-消费者模型,就可以实现事件驱动的服务器 ----------- 一旦有连接请求,就启动工作,没有连接请求,线程就被挂起。
而生产者-消费者模型的实现主要依赖于信号量。见下节描述。
1.3 信号量、事件驱动的服务器
信号量就是一种非负的全局变量。Linux对这种非负的全局变量有一组特殊的操作:P操作和V操作。
- P操作,其实就是将指定的信号量-1, 如果该信号量已经是0了,那么该操作就会导致线程被挂起。这里的-1操作和普通的-1操作的区别在于:P操作是一种元操作,不会被中断。相应的,普通的+1操作是可能被中断的,因为普通的-1操作是包含3个步骤:加载,更新,存储,这里的中断的意思是,比方说,程序加载了一个变量,准备更新,但是还没有更新的时候,控制权被转移到了其他程序,过了一会,控制权又回来,继续执行更新和存储的操作,这不是我们想要的。我们的P操作,是“加载、更新、存储”一气呵成的,相当于cpu最底层的一条指令。
- V操作, 其实就是将制定的信号量+1 。 同样的,这是一个元操作。此外,V操作会导致其他由于P操作挂起的线程被唤醒一个。
初值被设为1的信号量称为二元信号量,通常用来实现对临界资源的互斥访问,因此也成为互斥锁,对互斥锁的P操作,称为加锁,对互斥锁的V操作称为解锁。
初值被设为大于1的信号量被称为计数信号量,用来表征可用的共享资源的数量。
P操作和V操作这种挂起和唤醒的行为,结合互斥锁和计数信号量,使得我们很容易实现一种生产者-消费者模型。其中,互斥锁用于实现对缓冲区的互斥访问,计数信号量则用于标识可用空间的数目和可用项目的数目。
所谓生产者-消费者模型,就是一个缓冲区结构体 + 一组函数。这组函数包括对缓冲区的初始化、销毁、插入和删除。这个缓冲区作为全局变量的共享对象,这组函数在访问缓冲区的时候需要完成一些同步操作,就是获得缓冲区的互斥锁等一些PV操作。
有了这个模型,我们就可以实现事件驱动的服务器了,也就是说,一个线程可能会由于没有已连接描述符而被挂起,而一旦有了已连接描述符,主线程往缓冲区插入这个已连接描述符,这个插入操作中的V操作会唤醒一个消费者线程,也就是说这个消费者线程的取描述符操作不再被阻塞,从而可以执行任务。
1.4 多核执行多线程
多线程不一定需要多核,但是多核是实现多线程并行的唯一方式,注意,不是并发。
执行多核并行处理程序是一门单独的艺术(技术),他有一个任务分发的问题,主线程在创建多个对等线程的时候,会分配线程id,而线程函数则根据自己的线程id来“领取”数据,执行处理过程,最后主线程再将各个线程的结果做合并,从而得到最终结果。
多线程程序的执行由于需要完成一些同步操作,这些操作的开销不容小觑,在处理不当的情况下,可能多线程的并行程序反而比单线程更慢。
衡量多线程程序优劣有一些指标,除了绝对的运行时间外,还有加速比和效率。其中加速比主要描述将程序并行化带来的时间提升。
1.5 线程安全函数
“线程安全”是一个形容词,修饰函数的。当且仅当多个线程重复调用一个函数,而这个函数总是能够产生正确的结果的时候,我们称这个函数是线程安全的。
我们当然希望所有的函数都是线程安全的,但是事实并不如此。非线程安全的函数可以分为4类:
- 不使用PV操作来保护共享对象的函数。这样,多个线程以任意顺序执行并访问共享变量的时候,就可能会出现读写错误,从而不能得到正确的结果。例如,两个线程都对一个共享变量执行+1操作,那么执行完后,可能最终结果并不是+2. (这里要补充共享对象的概念:可以被多个线程引用的对象,可以是全局变量,静态变量等。)
- 每次执行结果依赖于上一次执行的函数。在多线程的时候,由于多个线程都会执行这个函数,而线程彼此的执行次序是未知的,因此“上一次”执行的是哪个步骤就会无法确定。例如rand函数,他生成的每个数字依赖于上一次调用rand的结果,而上一次调用rand可能发生在另一个线程中,而这并不是我们想要的。因此rand函数并不是线程安全的。
- 用静态变量来存储返回值的函数。在一个线程中执行该函数,在返回之前,可能跳转到另一个线程,也执行了这个函数,并且将结果也存储在了这个静态变量中,那么第一个线程的执行过程就白费了,这不是我们想要的。要将这种非线程安全函数改变成线程安全函数其实很简单,只要用一组PV操作将整个函数包起来,然后用局部变量来转储结果,即可得到一个线程安全的版本。缺点是严重影响效率。
- 调用非线程安全函数的函数。注意,这种函数并不一定就是非线程安全的,典型的例子就是上一条说的,我们正是利用这种调用来实现从非线程安全到线程安全的转化。但是,如果调用的是第2种非线程安全的函数,那么无论如何操作,这个函数也还是非线程安全的。
1.6 可重入函数
可重入函数是线程安全函数的特例。从前面的介绍可以发现,函数的非线程安全都是和共享变量有关系,那么,如果一个函数不涉及任何共享变量,那么他每次执行的结果就一定是可以预见的,因此是绝对的线程安全,我们称之为可重入函数。
如何判断一个函数是否可重入?两种方法:
- 如果一个函数的参数不涉及引用或指针,并且内部不涉及任何共享变量,那么肯定是可重入的,这叫做显式可重入。
- 如果一个函数的参数有引用或指针,并且内部不涉及任何共享变量,那么也可能是可重入的,需要小心处理。这时候的可重入称为隐式可重入。
明日目标
- unp第2章和第3章看完。因为这部分知识比较基础,之前都看过了。复习起来应该比较快,主要解答一些疑惑就够了。大概过一遍,能看懂,后面忘了再回来查。
- 看看面经吧,毕竟要面试了,同时也涨涨视野。
- LeetCode一道题。
任务有点多,早睡早起!晚安~