操作系统原理-线程

之前讨论的进程是具有单个控制线程的程序,但现代操作系统的设计都允许一个进程包含多个线程

进程和线程的区别

  1. 进程是执行中的程序,是程序的主动实体,是系统分配资源的最小单位;单个进程中执行某一任务就是一个线程,线程是CPU调度和任务执行的最小单位
  2. 一个进程可以拥有多个线程,但一个线程只能从属于一个进程
  3. 进程地址空间独立,由PCB管理;线程包含在进程的地址空间中。
  4. 所有线程共享所属进程的资源(文本段,数据段,堆区),但是拥有自己的一组寄存器,栈区。所以创建线程和切换线程的开销较小
  5. 进程内的任何线程处于相同的级别。进程内的任何线程都可以销毁、挂起、恢复和更改其它线程的优先权。线程也可以对进程施加控制,进程中任何线程都可以通过销毁主线程来销毁进程。

概述

每个线程是CPU使用的基本单元,包括线程ID,线程计数器,寄存器和堆栈,与同一进程的其他线程共享代码段数据段和操作系统资源,如果一个进程有多个线程,那么能同时执行多个任务。

作用

  1. 多线程进程可以同时执行多个任务,如一个Web服务器,传统方式是接受请求后创建进程来处理请求,但是问题是新进程和原进程执行同样的任务,那么为什么不能共享操作系统的资源?多线程服务器更加高效,它创建线程来处理请求,并恢复监听其他请求
  2. 在RPC过程中,RPC服务器是多线程的,服务器收到消息,创建线程来处理消息。
  3. 大多数操作系统的内核是多线程,每个线程执行一个特定任务

优点

  • 多线程部分阻塞时,程序也可以继续执行, 优化对用户的响应程度
  • 资源共享:进程只能通过共享内存或消息传递方式来共享资源,但是线程默认共享所属进程的内存和资源。
  • 经济性:进程创建所需的内存和资源都很昂贵,线程由于可以共享所属进程的资源,所以创建和切换更加经济
  • 多线程可以在多个处理器核上并行运行,而单线程只能运行在一个CPU核上

多核编程

多核系统的每个核都可以执行一个单独线程,

并发和并行:并行系统同时执行多个任务,并发系统支持多个任务,允许多个任务都可以取得进展,但是不一定同时执行。

多核编程的难点

  • 识别任务 将应用程序分为独立的、并发的任务,理想情况下任务是相互独立的,可以在多核上并行运行
  • 任务平衡 确保任务执行同等大小的工作
  • 数据分割 由任务访问和操作的数据应当划分
  • 数据依赖 如果一个任务依赖于另一个任务的数据时,程序需要确保任务执行是同步的。
  • 测试 多线程程序的执行路径不定,所以调试较为困难

并行类型

数据并行注重将数据分布于多个计算核上,并在每个核上执行相同的操作;
任务并行将任务分配到不同的计算核,每个线程执行一个独特的操作,不同线程可以操作相同的数据或者不同的数据。

多线程模型

用户层的用户线程和内核层的内核线程。用户线程位于内核之上,无需内核支持,内核线程由操作系统直接支持管理。

多对一模型

将多个用户线程映射到一个内核线程,线程管理是由用户空间的线程库完成的,

  1. 如果一个线程执行了阻塞的系统调用,那么整个进程阻塞。
  2. 另外由于同一时间只能有一个线程访问内核,所以不能在多核系统并行运行多个线程
  3. 由于不能利用多个处理核 所以基本不再使用

一对一模型

将每个用户线程映射到内核线程,允许在一个线程执行阻塞调用时,另一个线程继续执行,且支持多个线程并行运行在多处理器系统上。唯一缺点是创建一个用户线程就要创建相应的内核线程,会影响程序的性能(linux模型)

多对多模型

该模型复用多个用户线程到同样数量或更少数量的内核线程。

总结

  • 多对一模型允许开发者创建多个用户线程,但是只有一个内核线程,故未增加并发性
  • 一对一模型提供了并发性,但是系统可能会限制创建线程的数量
  • 多对多模型没有以上缺点,可以创建任意多的用户线程,且相应内核线程可以在处理器上并发执行。线程阻塞时,另一个线程也可以继续执行

线程库

线程库是提供给程序员的创建和管理线程的API,一种方法是在用户空间中提供一个没有内核支持的库,代码和数据结构是位于用户空间的;调用函数只是用户空间的本地调用,而非系统调用。
另一种方法是实现由操作系统支持的内核级的库,代码和数据位于内核空间

对于POSIX和Windows线程,全局声明的数据可由同一进程的所有线程共享。

  1. 异步线程和同步线程: 异步线程中,一旦父线程创建了子线程。父线程恢复自身的执行,且与子线程并发执行,每个线程的运行独立于其他线程,且很少数据共享。
  2. 如果父线程创建了子线程,需要等待所有子线程终止后才能恢复执行,就为同步线程,每个线程完成工作之后中止,与父线程连接

Pthreads

pthread是POSIX定义的线程创建与同步API,是线程行为的规范而不是实现

#include 
#include 
int sum;
void *runner(void *param);
int main(int argc,char **argv)
{
	pthread_t tid;
	pthread_attr_t attr;
	if(argc!=2)
	{	fprintf(stderr,"useage:a.out \n");
		return -1;
	}
	if(atoi(argv[1]<0))
	{
		fprintf(stderr,"%d must be >=0\n",atoi(argv[1]));
		return -1;
	}
	pthread_attr_init(&attr);
	pthread_create(&tid,&attr,runner,argv[1]);
	pthread_join(tid,NULL);
	printf("sun = %dn",sum);
}	
void *runner(void *param)
{
	int i,upper = atoi(param);
	sum=0;
	for(int i=1;i<upper;i++)
		sum+=i;
	pthread_exit(0);
}
  1. 独立线程通过特定函数执行,程序开始时,单个控制线程从main()开始,之后创建了runner()线程,共享全局数据sum
  2. pthread_attr_t表示线程的属性,包括堆栈大小,调度信息等,pthread_create()创建单独线程
  3. pthread_join()等待子线程完成,pthread_exit()终止线程

Windows线程

  1. 各个线程共享的数据声明为全局变量,Summation()以便在单独线程中执行,还需要传递给该函数一个void指针
  2. CreateThread() 传递给该函数安全信息,堆栈大小,调度标志,
  3. 创建累加线程后,父线程调用WaitForSingleObject() 阻塞,直到累加和线程退出
  4. WaitForMultipleObjects()接受四个参数,等待多个线程完成:等待对象数量、对象数组指针、是否等待所有对象、超时时间

Java线程

java的线程是程序执行的基本模型,所有java程序至少包含一个控制线程,即使只有main()的一个简单java程序也是在JVM中作为一个线程运行的

  1. 一种方法是创建继承Thread的新的类,并重载函数run() 另一种是定义一个实现接口Runnable()的类.
public interface Runnable
{
	public abstract void run();
}

对于Windows和Pthread 线程间数据共享可简单声明为全局数,而java必须向相应线程传递共享对象引用来实现。

隐式多线程

一种方法是将多线程的创建和管理交给编译器和RTE library来完成,这种策略成为隐式线程

线程池

因为创建线程需要开销,而且完成任务后线程会被丢弃,且如果并发请求过多,无限制地创建线程可能会耗尽系统资源,所以用线程池来管理线程

  1. 线程池:在进程开始时创建一定数量的线程,加到池中以等待工作,当有请求时,唤醒池中的一个线程,并将请求转发给它,线程完成任务后回到池中,如果池中没有可用线程,那么服务器阻塞,直到有可用线程为止
  2. 优点:现有线程执行服务r比创建新线程快;限制了最大线程数量;将执行任务和创建任务分离,从而可以控制执行任务的策略。

线程池的线程数量可以通过一些因素加以估算,也可以动态调整,负荷低时提供小规模的池,减少内存消耗

DWORD WINAPI PoolFunction(AVOID Param){}

PoolFunction()的指针传递给线程池API中一个函数(e.g.)QueueUserWorkItem(LPTHREAD START ROUTINE Function, PVOID Param, ULONG Flags)参数分别为作为单独线程运行的函数指针,传递给Functinon的参数,指示创建和管理线程的标志 。调用后,池中线程会运行PoolFunction()

OpenMP

OpenMP识别并行区域,通过编译指令来指示OpenMP来并行执行这些代码块

int main()
{
	#pragma omp parallel
	{
		printf("I am a parallel region.");
	}
	return 0;
}

OpenMP遇到#pragma omp parallel 就会创建与系统处理核一样多的线程,并同时执行该区域。它还提供了一些其他指令比如循环的并行化 #pragma omp parallel for 还可指定哪些数据可以在线程间共享,哪些只能属于某个线程

大中央调度 (Mac OS)

大中央调度(GCD) 为C/C++增加了块的扩展,每个块是工作的一个独立单元^{ printf("I am a block") } 这些块放在调度队列上,GCD再调度这些块的执行。当GCD从队列中选择并移除一个块后,就将该块分配给线程池内的可用线程。

  1. GCD识别串行和并发两种调度队列,串行队列上的块按照先进先出的顺序选择,一旦块被选择,则必须等待该块执行完后才能继续选择另一个块;每个进程都有自己的串行队列(主队列) 。
  2. 并行队列上的块也按先进先出的顺序删除,但是可以同时选择多个块从而让多个块并行运行。存在三个系统级的并发调度队列,优先级分别为低,默认和高。

多线程问题

系统调用fork()和exec()

  1. 如果某个线程调用fork()那么新进程复制所有线程或者只复制调用了fork()的线程(依赖于系统实现)
  2. 而如果一个线程调用了exec() 那么新执行的程序会替换整个进程。
  3. 所以如果在fork之后就调用exec 那么fork没有必要复制所有线程,但如果fork之后不调用exec就可能有必要复制所有线程。

信号处理

UNIX信号用来通知进程某个特定事件已经发生,信号的接收可以是同步的或者异步的。

  • 信号是由特定的事件发生而产生的
  • 信号被传递给某个进程
  • 一旦收到信号就应当处理

同步信号如非法访问内存和除0,异步信号是由运行程序之外的事件发生而产生的信号。

  1. 对于多线程程序来说,产生的信号应当传递给哪个线程呢?
  • 传递给信号所适用的线程
  • 传递给进程内的每个线程
  • 传递给进程内的某些线程
  • 规定一个特定线程以接收所有信号
  1. 同步信号传递给产生信号的线程,而一些异步信号传递到了所有线程
  2. 传递信号的函数为kill(pid_t pid, int signal) 一般线程可以指定它接收什么信号,拒绝什么信号,因此一个异步信号只能传递给不拒绝的线程,通常传递给第一个不拒绝它的线程。POSIX 提供函数pthread_kill(pthread_t tid, int signal)
  3. Windows 不显式提供信号支持,但允许通过异步过程调用APC来模拟, APC允许用户线程指定函数以在收到特定事件通知时调用。

线程撤销

线程撤销是在线程完成之前终止线程。

  1. 需要撤销的线程称为目标线程,目标线程有异步撤销(一个线程立即终止目标线程) 和延迟撤销(目标线程不断检查它是否应当终止,这允许目标线程有机会终止自己)
  2. 异步撤销时系统收回撤销线程的系统资源而非所有资源,延迟撤销时一个线程指示目标线程会被撤销,而仅当目标线程检测到标志位指示它应当撤销时才会撤销。进而可以安全地撤销并回收资源
  3. pthread提供了pthread_cancel() 来撤销线程,默认的撤销类型为延迟撤销,只有线程到达撤销点时才会发生撤销一般在撤销点会调用pthread_testcancel() 。这个函数会检查是否有撤销请求,如果有就调用清理程序 进而释放资源。

线程本地存储

  1. 同一进程的线程共享进程数据,这也是多线程编程的优点之一,但在某些情况下,线程需要私有某些数据,即线程本地存储(Thread-local storage)
  2. TLS与局部变量容易混淆,然而局部变量只在函数体内部时才可见,而TLS可以跨越函数调用可见,TLS可以看作是静态变量,但与静态变量不同是,TLS是每个线程特有的。

通信机制:调度器激活

许多系统在实现多对多或者双层模型时,在用户和内核线程之间增加一个中间数据结构:轻量级进程LWP

  1. 对于用户线程库,LWP表现为虚拟处理器以便用户程序调度并运行用户线程,每个LWP与一个内核线程相连,如果内核线程阻塞,则LWP也会阻塞,则链接到LWP上的用户线程都会阻塞。(只有内核线程才能被操作系统调度从而运行于物理处理器上)
  2. 用户线程库和内核的一种通信方案为 调度器激活: 内核提供一组LWP(虚拟处理器)给应用程序,而应用程序可以调度用户线程到任意一个可用的虚拟处理器
  3. 内核通知应用特定事件,这一步骤为回调,而应用通过回调处理程序处理事件

一个例子:
一个应用的线程要阻塞,即发生了一个触发回调的事件,内核会向应用发一个回调,再分配一个虚拟处理器给应用,应用在该虚拟处理器上运行回调处理程序,保存阻塞线程状态,释放阻塞线程的虚拟处理器,接着调度另一个适合在新分配的虚拟处理器上运行的线程。而阻塞线程的等待事件发生时,内核会向应用发出另一个回调,分配虚拟处理器,应用执行回调处理程序,并在该虚拟处理器上执行线程。

操作系统实现

Windows

  1. Windows 采用一对一模型,包括如下部分
  • 线程ID 标识线程
  • 寄存器组 标识处理器窗台
  • 用户堆栈/内核堆栈
  • 私有存储区
    寄存器组 用户堆栈和私有存储区通常称为线程上下文
  1. 主要数据结构有: ETHREAD 执行线程块 KTHREAD 内核线程块 TEB线程环境,ETHREAD包括线程所属进程的指针,线程开始程序地址和指向KTHREAD指针;KTHREAD包括线程调度和同步信息,内核堆栈和指向TEB的指针。ETHREAD和KTHREAD位于内核空间.
  2. TEB位于用户空间,包括线程标识符,用户堆栈和线程本地存储。

Linux

Linux不区分线程和进程,讨论控制流时一般用任务一词

  1. 可以通过调用clone() 来创建线程,但需要传递标志来确定父子任务的共享规则,如果共享内存空间,信号处理程序,文件系统等等,这种方式相当于线程创建,因为共享大部分的资源,如果不传递标志,则不共享,相当于进程创建。
  2. fork创建的新任务具有父进程数据结构的副本 而clone创建的任务保存指向父进程数据结构的指针。

习题

  1. 任何形式的顺序程序对多线程来说都不是好的形式;另一种是类似Shell程序,它们必须不断地检测自身的工作空间
  2. 在内核线程可能因为系统调用阻塞的情况下,如果是多内核线程的话,进程可以继续工作。
  3. 堆内存和全局变量会被共享,堆栈和寄存器是线程独有的
  4. 不能,除非采用多个内核线程,因为不能同时使用不同的处理器,操作系统只会看到单一的进程且不会在不同处理器调度上不同进程的线程,故无法取得性能优势。
  5. 如果在单个线程中打开网站,那么由于共享大多数资源,如果有一个网站崩溃,会造成整个进程的崩溃
  6. 有可能,进程调度就是并发,但是如果进程中只有一个线程,那么就没有并行了。
  7. (a) 10/7 (b) 20/11
  8. 任务并行; 数据并行;数据并行;任务并行
  9. 一个线程执行输入输出 因为I/O密集型不需要占用太多CPU时间,创建4个线程用于应用程序的CPU密集部分
  10. 最后总计10个单独进程 创建了4个线程 ,在其中的8个进程中。
  11. 如果进程和线程相似,那么一些代码可以简化,比如调度器可以平等地考虑线程或者进程;但是这种相似会造成对线程资源的限制比较困难。
  12. LINE C输出 5 LINE P输出0 因为是在子进程的线程中对value做了更改
  13. 内核线程数量小于处理核数量,会使得一些处理核为空闲状态;当内核线程与处理核数量相等,所有处理器可能同时适用,当内核线程阻塞,对应处理核闲置;如果内核线程大于处理核数量,可以进行内核线程的换入换出,增加多处理核的利用率
  14. 在线程撤销之前需要回收已经分配的资源,以便安全地撤销

你可能感兴趣的:(操作系统)