并发编程理解

  • 现代操作系统提供三种基本的构造并发的方法
    1. 进程
    2. I/O多路复用
    3. 线程

基于进程的并发服务器

  • ==逻辑流在时间上是重叠的,那么他们就是并发的==
  • 每个流使用了单独的进程,内核会自动的调度每个进程。
  • 在父进程当中接受客户端的请求,然后创建一个新的子进程为每个客户端提供服务。
简单,来说:
服务器监听描述符3上的请求。
如果客户端发出请求,那么服务器返回一个已连接的描述符4.
在接受连接之后,服务器派生一个子进程,这个子进程获得服务器描述符的完整拷贝。
子进程关闭拷贝当中监听描述符3,父进程关闭已连接的描述符4.
这样子进程就可以单独的服务于客户端。
父进程可以继续监听描述符3。
  • 关于进程的==优劣==:

    1. 父子进程之间共享文件表但是不共享用户内存地址
    2. 一个进程不可能覆盖另一个进程的虚拟存储器
    3. 但是独立的地址空间使得进程间共享信息变得困难,必须使用显示的IPC进程间通信机制
    4. 进程控制和IPC开销比较高
  • IPC进程间通信机制

    1. 允许进程发送消息到==同一台主机上的其他的进程==
    2. 套接字接口是一种重要的形式,允许==不同主机上的进程==交换任意的字节流
    3. 管道,先进先出,系统共享内存,信号量这些机制。

I/O多路复用

  • 服务器需要响应两个或者多个相互独立的事件,我们先等待哪个事件呢?
  • 解决办法就是I/O多路复用
  • 创建自己的逻辑流,显示的调度流。只有一个进程。
    基本思想:使用select函数,要求内核挂起进程,只有在一个或者多个I/O事件发生之后,才将控制返回给应用程序。
简单来说:
就是使用select函数
这个函数会一直阻塞直到读到描述符集合当中至少有一个描述符准备好可以读。
  • 将逻辑流转化为状态机
  • 一个状态机就是一组状态,输入事件,转移
  • 转移就是将状态和输入事件映射到状态

  • I/O多路复用的优劣

    1. 比基于进程的设计给程序员更多的对于程序的行为控制
    2. 运行在单一的上下文当中,每个逻辑流都能访问进程的全部地址空间,使得流之间共享数据变得容易
    3. 编码复杂
    4. 只要某个流正忙于读一行文本,其他的逻辑流就不可能有进展
    5. 不能充分利用多核处理器

基于线程的并发

  • 基于线程是前两者的混合。
  • 线程就是运行在进程上下文当中的逻辑流
  • 线程是内核自动调度的
  • 每个线程都有自己的上下文:一个唯一的整数 线程ID,栈,栈指针,程序计数器,通用目的寄存器,条件码。包括他的代码,数据,堆,共享库和打开的文件。
  • 和进程一样线程由内核自动调度
  • 和I/O多路复用一样,多个线程在同一个进程的上下文当中运行,共享整个内容包括代码,数据,堆,共享库和打开的文件。
简单来说:
每个进程开始生命周期时都是单一的线程,这个线程成为主线程。
在某个时刻,主线程会创建一个对等线程,从这个时间点开始两个线程就并发的执行。
主线程当执行一个慢速的系统调用例如read或者sleep的时候,或者他被系统的间隔计时器中断,控制就会通过上下文切换传递到对等线程。
对等线程执行一段时间,控制权传递回主线程,以此类推。
  • 线程上下文的切换比进程上下文切换快的多 上下文也小的多
  • 组织方式:不是按照严格的父子层次来组织的。
  • 和一个进程相关的线程组成一个==对等线程池==独立于其他线程创建的线程。
  • 主线程和其他线程的区别仅在于他总是进程当中第一个运行的线程
  • 一个线程可以杀死任何他的对等线程
  • 每个对等线程都能读写相同的共享数据。
  • POSIX线程是在从程序处理线程的一个标准接口,在大多数的UNIX系统上都可用

  • 线程的代码和本地数据都被封装在一个==线程例程==当中 (函数)
  • 每一个例程都以一个通用指针作为输入,返回一个通用指针 void*
  • 如果需要多个参数或者返回多个值,使用结构体 传入或者返回结构体的指针
  • 当pthread_create调用返回时主线程和新创建的对等线程同时运行
  • 通过调用pthread_join主线程等待对等线程终止
  • pthread_create函数创建一个新的线程,新线程在新的上下文当中运行线程例程f.

线程终止

  • 当顶层线程返回时线程也会隐式终止
  • 通过调用pthread_exit 函数 线程会显示的终止
  • 如果主线程调用上面这个函数,他会等待其他所有的对等线程终止再终止主线程和整个进程。
  • 线程通过调用pthread_join函数等待其他的线程终止
  • 这个函数会阻塞知道线程终止然后回收线程占用的所有的存储器资源

线程分离

  • 任何一个时刻线程是可结合的也是可分离的。
  • 一个==可结合==的线程能够被其他的线程回收资源和杀死。
  • 在被其他的线程回收之前他的存储器资源是没有被释放的。
  • 相反一个==分离的线程==是不能被其他的线程回收后者杀死的,他的存储器资源由系统自动释放。
  • 默认情况下线程被创建为可结合的。
  • 为了防止存储器泄漏,每个可结合的线程要么被其他线程显示的回收,要么通过pthread_detach函数分离
  • pthread_detach函数分离可结合线程的pid
  • 现实程序中使用的大多是==分离的线程==:高性能的web服务器每个对等线程都应该在开始处理请求之前分离他们自身,这样就能在他终止之后回收他的存储器资源。
  • 必须为每个accept返回的已连接描述符分配他们自己的动态分配的存储快防止竞争。
  • 既然我们不显示的回收线程必须分离每个线程。

基于进程的服务器当需要在子进程当中关闭监听描述符在父进程当中关闭连接描述符,在子进程结束的时候也关闭了已连接描述符,但是在线程服务器当中只有子线程当中关闭已连接描述符这是为什么?

  • 首先进程是两个单独的逻辑流,这两个逻辑流在子进程产生的时候是有一模一样的数据的,也就是说如果子进程不关闭监听描述符那么子进程按照逻辑流还会继续监听执行和父进程一样的逻辑,如果父进程没有在创建子进程之后关闭已连接描述符,那么子进程和父进程会同时连接到客户端,这样传递的数据会发生错误。
  • 对于线程来说,是在同样的上下文当中执行的不同的逻辑流,主线程并没有子线程的逻辑流(执行的代码),也就是说子进程执行的是自己独有的逻辑流,所以主线程在建立连接之后将已连接描述符传递给新建的子线程之后,因为没有与客户端通信的逻辑流所以不会和子线程产生冲突,也就没有必要关闭已连接描述符,同时子线程也没有监听描述符的逻辑所以也没有可能也不必要关闭该描述符。
  • ==所以,进程是相互独立的相同的逻辑流。线程是在相同上下文当中执行的不同的逻辑流。==

多线程当中的共享变量

  • 每个线程都有自己独立的线程上下文(这是不共享的就像上面问题主线程和子线程的执行代码是完全不同的),包括线程ID,栈,栈指针,程序计数器,条件码,通用目的寄存器。
  • 每个线程和其他线程一起==共享进程上下文的剩余部分==。包括整个用户的虚拟地址空间,由只读文本(代码)读写数据,堆,以及所有的共享代码库和数据区域组成。
  • 一个线程是不可能读写另一个线程的寄存器。任何线程都可以访问共享虚拟存储器。
  • 各自独立的线程的存储器模型不是那么齐整的,这些栈被保存在虚拟地址空间的栈区,通常是被相应的线程独立的访问的,但是不同的线程是对其他的线程不设防的,如果某一个线程以某种方式得到一个指向其他线程的指针,那么他就可以读写这个栈的任何部分。

将变量映射到存储器

  • 全局变量:全局变量定义在函数之外,运行时存储器的读写区只包含给个全局变量的一个实例,任何线程都可以引用。
  • 本地自动变量:就是定义在函数内部的没有static属性的变量。每个线程的栈包含自己所有的本地自动变量。
  • 本地静态变量:定义在函数内的static属性的变量,和全局变量一样虚拟内存的读写区只包含每个本地静态变量的一个实例,每个对等线程都可以读写这个实例。
  • 共享变量:当且仅当一个变量的实例被一个以上的线程引用。
  • 共享变量也引入了同步错误的可能性。
  • 没有办法预测操作系统是否会将为你的线程选择一个正确的顺序
  • 为了保证线程当中任何共享全局数据结构的并发程序的正确执行我们必须以某种方式进行同步线程。一个经典的方式是基于信号量。

信号量

  • 信号量s是具有==非负整数值的全局变量==,只能由两种特殊的操作来处理,P,V.
  • P操作 如果s非0那么P将S减一,立即返回。如果S为0就挂起这个线程直到s非0
  • V操作将S加一如果有任何线程阻塞在p操作等待s变成非0,那么v操作会重启这些线程中的一个
  • P,V操作是不可分隔的。
  • 当多个线程等待一个信号量时不能预测V操作要重启哪个线程。
  • 信号量不变性
  • P:测试 V:增加

使用信号量来实现互斥

基本思想:将每一个共享变量和一个信号量s初始化为1联系起来。然后使用P,V操作将相应的临界区包围起来。

  • 这种以提供互斥为目的的二元信号量称为==互斥锁==。
  • 在一个互斥锁上执行P操作称为对互斥锁加锁
  • 执行V操作称为对互斥锁解锁
  • 一个给互斥锁加锁但是还没有解锁的线程称为占用这个互斥锁。
  • 信号量操作保证了对临界区的互斥访问
  • volatile 类型修饰符,用来修饰被不同的线程访问和修改的变量
  • volatile变量说明这个变量可能会被意想不到的改变 这样编译器就不会假设这个变量的值了 不会进行优化或者怎么样。
一般说来,volatile用在如下的几个地方:
1、中断服务程序中修改的供其它程序检测的变量需要加volatile2、多任务环境下各任务间共享的标志应该加volatile3、存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能有不同意义;
另外,以上这几种情况经常还要同时考虑数据的完整性(相互关联的几个标志读了一半被打断了重写),在1中可以通过关中断来实现,2 中可以禁止任务调度,3中则只能依靠硬件的良好设计了。
  • 简单示例代码

volatile int cnt = 0;
sem_t mutex;

//主线程初始化mutex为1
Sem_init(&mutex,0,1);

//在线程例程当中对共享变量cnt的更新进行p,V操作
for(i=0;i<nitres;i++) {
    P(&mutex);
    cnt++;
    V(&mutex);
}
  • 信号量的另一个重要的作用:调度对于共享资源的访问
  • 经典例子:消费者和生产者,读者写者例子
  • 需要三个信号量进行同步,一个信号量提供互斥的缓冲区访问,另外两个分别记录空槽位和可用项目的数量
  • 读者-写者问题
有些线程只读对象其他线程只修改对象。
修改对象的线程叫做写者。
只读对象的线程叫做读者。
写者必须拥有对于对象的独占访问,
读者可以和无限多的其他读者共享对象。
  • 几个变种。每个都是基于读者和写者的优先级的。
  • java线程使用一个叫做java监视器的机制实现同步 是信号量更高级别的抽象

预线程化的并发服务器

  • 基于线程的服务器这中方法的一个缺点是我们为每一个新客户端创建新线程导致不小的代价。
  • 基于预线程化的服务器可以降低这种开销。
  • 服务器由一个主线程和一组工作组线程构成。
  • 每一个工作线程反复的从共享缓冲区取出描述符为客户服务,然后等待下一个描述符。
  • 并发程序在多核的机器上运行速度更快,因为操作系统内核可以在多个核上并行的调度这些并发的线程。

线程安全性

  • 一个函数被称为线程安全性的当且仅当多个并发的线程反复的调用这个函数时他会一直产生正确的结果。
  • 可以定义四个线程不安全的函数类:

    1. 不保护共享变量的函数
    2. 保持跨越多个调用的状态的函数;
    3. 返回指向静态变量的指针的函数
    4. 调用线程不安全的函数的函数
  • 竞争:当一个程序的正确性依赖于一个线程要在另一个线程到达v点之前到达他的控制流当中的x点时

  • 如果我们是否得到正确的答案依赖于内核是如何调度线程的执行,是非常危险的。
  • 需要动态的分配存储块。以免竞争。
  • 死锁 : 一组线程被阻塞了等待一个永远也不会为真的条件
  • 死锁是一个非常困难的问题因为他不可预测。
  • 互斥锁加锁顺序:线程都是按照相同的顺序对他们加锁解锁那么这个程序就是无死锁的。

猜你喜欢

转载自blog.csdn.net/zhc_24/article/details/80802685
今日推荐