随笔- 79  文章- 1  评论- 1  阅读- 1678 

进程和线程的管理

  • 多道程序设计:管理单处理器系统中的多个进程。
  • 多处理器技术:管理多处理器系统中的多个进程。
  • 分布式处理器技术:管理分布式环境下的多个进程。

并发出现的三种环境

  • 多应用程序:多个运行中的应用程序共享处理器时间。
  • 结构化应用程序:单个应用程序设计成多个并发进程。
  • OS 结构:部分 OS 自身作为一组进程或者线程来实现。

相关术语

  • 原子操作:一个或者多个指令构成的序列,满足如下要求:这个指令序列要么全部执行完成要么全部都不执行。
  • 临界区:一段访问共享资源的代码。
  • 死锁:2个或以上数量的进程分别同时占用部分资源、等待其他进程释放资源导致每个进程都无法执行的情况。
  • 活锁:2个或以上数量的进程因为响应其他进程的变化而不断改变自身状态、没有做任何有用的工作的情况。
  • 互斥:一个进程进入临界区访问共享资源,其他进程无法进入临界区的情况。
  • 竞争条件:多个进程(或者线程)同时读写一个共享数据,数据的最终结果由执行顺序决定的情况。
  • 饥饿:一个处于就绪态的进程长时间得不到执行的情况。
  • 临界资源:在一个时间点只能被一个程序访问的资源。
  • 临界区:程序中访问临界资源的那部分代码段。
  • 忙等待(自旋等待):进程在得到进入临界区的访问权之前,只能执行测试变量而不能做其他事情的情况。

并发的原理

单处理器系统中,多个进程交替执行,从外部表现出并发的特征。多处理器系统中,多个进程不仅可以交替执行,而且可以重叠执行,每个处理器可以在某个时间点执行一个进程。
由于进程的相对执行速度不可预测,出现了几个问题:1、全局资源共享存在危险,2、OS 难以对全局资源进行最优化分配,3、定位程序设计错误困难。

对于某个全局变量,一个进程甲修改了它的值,然后被中断。进程乙访问这个全局变量,也修改了它的值(假设该值与进程甲修改的值不同,这个概率极大),然后使用它,执行完毕。进程甲恢复执行,使用了它的值,执行完毕。这个时候进程甲使用的值是已经被乙改变了的值,得到的结果和预期不符。
对于该问题的解决方案是控制访问全局变量的方式。如当某个进程访问一个全局变量时,只有当该进程执行完毕,其他进程才能访问该变量。

竞争条件是多个进程或者线程读写数据时,最终结果取决于多个进程指令的执行顺序。

并发设计和管理相关问题:1、OS 需跟踪不同进程,2、OS 需要为每个活动进程分配和释放资源,3、OS 需保护每个进程的数据和使用中的资源免受其他进程的干扰,4、一个进程的功能和结果需和执行速度无关。

根据进程之间是否知道对方的存在的程度,可以将进程之间的交互分为三种:1.进程之间不知道对方的存在、2.进程间接知道对方的存在、3.进程直接知道对方的存在。

第一种情况下,多个进程竞争同一个资源时,会发生冲突。一个进程的执行会影响到与之竞争的进程的行为(主要是对资源的访问)。竞争相关的控制问题有三个:互斥、死锁和饥饿。第二种情况下,相关的控制问题有互斥、死锁、饥饿、数据完整性和数据一致性(主要是对共享数据的访问)。第三种情况下,进程之间通过通信合作,相关的控制问题有死锁和饥饿。

支持互斥需要满足的要求

  1. 多个进程的临界区访问同一个资源或者共享对象时,在任何时刻只允许一个进程进入临界区。
  2. 在非临界区停止的进程不能妨碍其他进程。
  3. 不允许需要进入临界区的进程无限延迟。
  4. 没有进程处于临界区中时,需要进入临界区的进程可以立刻进入临界区。
  5. 对相关进程的执行速度和处理器的数量没有限制。
  6. 进程驻留在临界区中的时间是有限的。

互斥:硬件支持

对于单处理器系统,在进入临界区时禁用中断功能,保证进入临界区中的进程不会被中断。该进程从临界区出来之后,启用中断。这个方法可以保证进程访问临界区是互斥的。

对于单处理器系统或者多处理器系统,在一个指令周期内,保证两个动作具有原子性。如对某个内存单元进行读和写或者读和测试。

/*
** 如果内存单元中的值和测试值相等,更新内存中的值,返回内存中原先的值
*/
int compareAndSwap(int *word, int test, int new) {
  int old = *word;
  if (old == test) {
    *word = new;
  }
  return old;
}

const int n = /* 进程数量 */;
int bolt;

// 当 bolt 为 0 时,进入临界区
void P(int i) {
  while (true) {
    while (compareAndSwap(bolt, 0, 1) == 1)
      ;
    // 临界区
    bolt = 0;
    // 其他部分
  }
}

void main() {
  bolt = 0;
  // 多个进程并发执行
  parbegin(P(1),P(2)...P(n));
}
/*
** 内存和寄存器内容交换
*/
void exchange(int *register, int *memory) {
  int temp = *register;
  *register = *memory;
  *memory = temp;
}

const int n;
int bolt;

void P(int i) {
  while (true) {
    int key = 1;
    do {
      exchange(key, bolt);
    } while(key != 0);
    // 临界区
    bolt = 0;
    // 其他部分
  }
}

void main() {
  bolt = 0;
  parbegin(P(1),P(2)...P(n));
}

机器指令实施互斥
优点:

  1. 适用于单处理器或者多处理器的任意数量进程。
  2. 简单且易于证明。
  3. 可支持多个临界区。

缺点:

  1. 使用了忙等待,浪费处理器时间。
  2. 可能发生饥饿。
  3. 可能发生死锁。

信号量

相关术语

  • 信号量:又称计数信号量(counting semaphore)或者一般信号量(general semaphore),用于进程之间传递信号的整数值,可进行三种操作:初始化、递减和递增。
  • 二元信号量:值只能为 0 或 1 的信号量。
  • 互斥量:类似二元信号量,区别是为其加锁(设置值为 0)和解锁(设置值为 1)的进程必须是同一个进程。
  • 条件变量:一种数据结构,用于阻塞进程或者线程,直到条件为真。
  • 管程:一种结构,在抽象数据类型中定义了变量、过程和初始化代码。变量只能由结构自身的过程访问,每次仅允许一个进程处于管程中。管程可以有一个等待队列。
  • 事件标志:用于同步的一个内存字,每一位和一个事件相关联,满足条件之前,进程或者线程一直被阻塞。
  • 信箱/消息:两个进程用于交换信息的一种方法,可用于同步。
  • 自旋锁:互斥机制,进程在无限循环中等待锁变量可用。

信号量可当作一个整数值,有如下三种操作:

  1. 可初始化为非负整数。
  2. semWait 操作使信号量减 1,如果值小于 0,则调用 semWait 的进程阻塞,否则进程继续执行。
  3. semSignal 操作使信号量加 1,如果值小于等于 0,则从被 semWait 操作阻塞的进程队列中选取一个进程解除阻塞。

信号量的三个结论:

  1. 在进程对信号量执行减 1 操作之前,无法得知该进程是否会被阻塞。
  2. 在单处理器系统中,进程对信号量执行加 1 操作之后,会唤醒一个处于阻塞态的另一个进程,两个进程继续并发执行(多处理器系统中则并行运行),无法得知哪个进程会立即继续执行(加 1 操作的进程还是被唤醒的进程)。
  3. 进程对信号量执行加 1 操作之后,可能有阻塞态进程被唤醒,也可能没有进程被唤醒(此时没有进程处于阻塞态)。
/*
** 信号量
*/
struct semaphore {
  int count;
  queueType queue;
};

void semWait(semaphore s) {
  s.count--;
  if (s.count < 0) {
    // 阻塞当前进程并插入阻塞队列
  }
}

void semSingal(semaphore s) {
  s.count++;
  if (s.count <= 0) {
    // 将某个阻塞进程从阻塞队列移除并插入到就绪队列中
  }
}
/*
** 二元信号量
*/
struct binarySemaphore {
  enum {zero, one} value;
  queueType queue;
};

void semWait(semaphore s) {
  if (s.value == one) {
    s.value = zero;
  } else {
    // 阻塞当前队列并插入阻塞队列
  }
}

void semSignal(semaphore s) {
  if (s.queue is empty) {
    s.value = one;
  } else {
    // 将某个阻塞进程从阻塞队列移除并插入到就绪队列中
  }
}

和信号量关联的阻塞队列采用先进先出方式移除阻塞进程,这种信号量称为 强信号量;没有采取策略的称为 弱信号量

/*
** 用信号量解决互斥
** s.count >= 0:s.count是可以执行semWait而不被阻塞的进程(执行期间没有semSingal执行)。
** s.count < 0:被阻塞在s.queue队列中的进程数量。
*/
const int n = /* 进程数 */;
semaphore s = 1;

void P(int i) {
  while (true) {
    semWait(s);
    // 临界区
    semSignal(s);
    // 其他部分
  }
}

void main() {
  parbegin(P(1),P(2)...P(n));
}

生产者和消费者问题:有一个或者多个生产者生产某种数据并放入缓冲区中,有一个消费者从缓冲区中取出数据,每次取一项。在某一时间点,只允许一个生产者向缓冲区放入数据或者消费者取数据。在缓冲区满时,不允许生产者放入数据;缓冲区空时,不允许消费者取数据。

/*
** 假设缓冲区大小是无限的
** in表示生产的数据在缓冲区的索引、out表示将要取出的数据所在的索引
*/

// producer
while (true) {
  // 生产 v
  b[in] = v;
  in++;
}

// consumer
while (true) {
  while (in <= out) {
    w = b[out];
    out++;
    // 消费 w
  }
}
/*
** 二元信号量实现生产者和消费者问题
** 假设缓冲区大小是无限的
*/
int n; // 缓冲区中数据的数量
binary semaphore s = 1, delay = 0;

void producer() {
  while (true) {
    produce();
    semWait(s);
    append();
    n++;
    if (n == 1) {
      semSiganl(delay);
    }
    semSingal(s);
  }
}

void consumer() {
  semWait(delay);
  while (true) {
    semWait(s);
    take();
    n--;
    int m = n;
    semSignal(s);
    consume();
    if (m == 0) {
      semWait(delay);
    }
  }
}

void main() {
  n = 0;
  parbegin(producer, consumer);
}
/*
** 一般信号量解决生产者消费者问题
*/
semaphore n = 0, s = 1;

void producer() {
  while (true) {
    produce();
    semWait(s);
    append();
    semSingal(s);
    semSingal(n);
  }
}

void consumer() {
  while (true) {
    semWait(n);
    semWait(s);
    take();
    semSignal(s);
    consume();
  }
}

void main() {
  parbegin(producer, consumer);
}
/*
** 缓冲区有限的情况下使用一般信号量
*/

// producer
while (true) {
  // create v
  while ((in + 1) % n == out)
    ;
  b[in] = v;
  in = (in + 1) % n;
}

// consumer
while (true) {
  while (in == out)
    ;
  w = b[out];
  out = (out + 1) % n;
  // consume w
}

const int sizeOfBuffer = /* 缓冲区大小 */;
semaphore s = 1, n = 0, e = sizeOfBuffer;

void producer() {
  while (true) {
    produce();
    semWait(e);
    semWait(s);
    append();
    semSingal(s);
    semSingal(n);
  }
}

void consumer() {
  while (true) {
    semWait(n);
    semWait(s);
    take();
    semSingal(s);
    semSignal(e);
    consume();
  }
}

void main() {
  parbegin(producer, consumer);
}

信号量的实现

/*
** 硬件实现
*/
semWait(s) {
  while (compareAndSwap(s.flag, 0, 1) == 1)
    ;
  s.count--;
  if (s.count < 0) {
    // 该进程加入s.queue队列,阻塞
  }
  s.flag = 0;
}

semSignal(s) {
  while (compareAndSwap(s.flag, 0, 1) == 1)
    ;
  s.count++;
  if (s.count <= 0) {
    // 将某个阻塞进程放入就绪队列
  }
  s.flag = 0;
}


/*
** 单处理器系统中断实现
*/
semWait(s) {
  禁用中断
  s.count--;
  if (s.count < 0) {
    // 该进程加入s.queue队列,阻塞,允许中断
  } else {
    允许中断
  }
}

semSignal(s) {
  禁用中断
  s.count++;
  if (s.count <= 0) {
    // 将某个阻塞进程放入就绪队列
  }
  允许中断
}

管程

Haroe 提出的管程(monitor)定义如下:
管程是由一个或者多个过程、一个初始化序列和局部数据组成的软件模块。主要特点:

  1. 管程中的局部数据只能被管程中定义的过程访问。
  2. 进程通过调用管程中定义的过程进入管程。
  3. 任何时候,只能有一个进程处于管程中。

管程通过条件变量支持同步,条件变量包含在管程中,且只能在管程内访问。有两个函数用于操纵条件变量:

  1. wait(s):调用该函数的进程在条件 s 上阻塞,管程现在可被其他进程使用。
  2. signal(s):恢复执行因条件 s 被阻塞的阻塞队列中的一个进程;如果没有阻塞进程,则什么也不做。
/*
** 管程解决生产者消费者问题
*/
monitor boundedbuffer;
char buffer[N];
int nextIn, nextOut;
int count;
cond notFull, notEmpty;

void append(char x) {
  if (count == N) {
    cwait(notFull);
  }
  buffer[nextIn] = x;
  nextIn = (nextIn + 1) % N;
  count++;
  csignal(notEmpty);
}

void take(char x) {
  if (count == 0) {
    cwait(notEmpty);
  }
  x = buffer[nextOut];
  nextOut = (nextOut + 1) % N;
  count--;
  csignal(notFull);
}

// 初始化块
{
  nextIn = 0;
  nextOut = 0;
  count = 0;
}

void producer() {
  char x;
  while (true) {
    produce(x);
    append(x);
  }
}

void consumer() {
  char x;
  while (true) {
    take(x);
    consume(x);
  }
}

void main() {
  parbegin(producer, consumer);
}

管程优于信号量的地方在于,所有的同步机制处于管程内部,易于验证同步的正确性,易于检测错误。

Hoare 提出的管程存在两个缺陷:

  1. 如果产生 signal 的进程当前没有执行完,需要进行两次切换:阻塞该进程一次,管程可用时一次。
  2. 与信号相关的进程调度需要非常可靠。如果发出了 signal 信号,此时另一个进程进入了管程;在发出信号前进程失败,都会造成错误。

Lampson 和 Redell 改进了管程,csignal 被 cnotify 替代。cnotify 表示,当一个管程中的进程执行了 notify(x),则会通知和 x 相关的阻塞队列,此进程继续执行。

/*
** 改进的管程
*/
void append() {
  // 第一次进入时检查一次,当阻塞之后恢复时再检查一次
  while (count == N) {
    cwait(notFull);
  }
  buffer[nextIn] = x;
  nextIn = (nextIn + 1) % N;
  count++;
  cnotify(notEmpty);
}

void take(char x) {
  while (count == 0) {
    cwait(notEmpty);
  }
  x = buffer[nextOut];
  nextOut = (nextOut + 1) % N;
  count--;
  cnotify(notFull);
}

可以改进的地方有:

  1. 给每一个因特定的条件变量阻塞的队列设置一个计时器,当计时器超时,将队列中的阻塞进程调入就绪态,防止某些进程长时间得不到处理出现饥饿。
  2. 当 cnotify 执行时,将特定条件变量相关的队列中的阻塞进程通过广播的方式将它们调入就绪队列。

改进的优点:更加符合模块化的方法。

消息传递

通过一对原语的形式提供功能。
send(destination, message) 将消息传递给 destination 进程。
receive(source, message) 接收来自 source 进程的消息。

根据发送者和接收者在发送/接收消息的时候是否阻塞可分为以下几种情况:

  1. 阻塞 send 和 receive:发送者和接收者都被阻塞,直到完成消息的传递。又称会合(rendezvous)。
  2. 不阻塞 send,阻塞 receive:阻塞接收者,不阻塞发送者。
  3. 不阻塞 send 和 receive:不要求任何一方等待。

消息在传递时确定目标进程的方式分为直接寻址和间接寻址。
直接寻址:destination 为接收进程的 PID,source 为发送进程的 PID 或者在不需要确定具体发送进程时 source 保存接收进程执行完成后的返回值。
间接寻址:发送进程将消息发送到一个临时的消息队列中,接收进程从消息队列中取消息。这个消息队列称为信箱(mailbox)。

间接寻址根据发送者和接收者的数量又可以分为:一对一、一对多、多对一和多对多四种。

可变长消息的格式为消息头和包含具体内容的消息体。消息头包含目的进程和源进程的 PID,消息长度、消息类型和其他控制信息等。

消息在队列中排队原则为 FIFO 或者根据优先级进和出。

/*
** 消息实现互斥
** 当接收到一个消息时,才允许进入临界区。消息充当了令牌的角色。
*/

const int n = /* 进程数量 */;

void P(int i) {
  message msg;
  while (true) {
    receive(box, msg);
    // 临界区
    send(box, msg);
    // 其他部分
  }
}

void main() {
  create mailbox box;
  send(box, null);
  parbegin(P(1),P(2)...P(n));
}
/*
** 消息解决生产者消费者问题
** mayproduce 中为 null 时,生产者进入临界区
*/
const int capacity = /* 缓冲区容量 */;

void producer() {
  message pmsg;
  while (true) {
    receive(mayproduce, pmsg);
    pmsg = produce();
    send(mayconsume, pmsg);
  }
}

void consumer() {
  message cmsg;
  while (true) {
    receive(mayconsume, cmsg);
    consume(cmsg);
    send(mayproduce, null);
  }
}

void main() {
  create mailbox mayproduce and mayconsume;
  for (int i = 1; i <= capacity; i++) {
    send(mayproduce, null);
  }
  parbegin(producer, consumer);
}

读者/写者问题

读者写者问题:有一个共享数据区,被多个进程共享。部分进程仅读取这个数据区中的数据,部分进程仅向这个数据区中写入数据。任意数量的读进程可以同时读;任何时间点仅允许一个写进程在写数据,同时不允许读进程读。

/*
** 读者优先
** 第一个读进程进入临界区,不允许写进程进入;当所有读进程离开临界区,才允许写进程进入
*/
int readcount;
semaphore x = 1, wsem = 1;

void reader() {
  while (true) {
    semWait(x);
    readcount++;
    if (readcount == 1) {
      semWait(wsem);
    }
    semSignal(x);
    read();
    semWait(x);
    readcount--;
    if (readcount == 0) {
      semSignal(wsem);
    }
    semSignal(x);
  }
}

void writer() {
  while (true) {
    semWait(wsem);
    write();
    semSignal(wsem);
  }
}

void main() {
  readcount = 0;
  parbegin(reader, writer);
}
/*
** 当写进程想写的时候,有阻止读进程读的机会
*/
int readcount, writecount;
semaphore x = 1, y = 1, z = 1, rsem = 1, wsem = 1;

void reader() {
  while (true) {
    semWait(z); // 多个读进程在此处阻塞
    semWait(rsem);
    semWait(x);
    readcount++;
    if (readcount == 1) {
      semWait(wsem);
    }
    semSignal(x);
    semSignal(rsem);
    semSignal(z);

    read();

    semWait(x);
    readcount--;
    if (readcount == 0) {
      semSignal(wsem);
    }
    semSignal(x);
  }
}

void writer() {
  while (true) {
    semWait(y);
    writecount++;
    if (writecount == 1) {
      semWait(rsem);
    }
    semSignal(y);
    semWait(wsem);
    write();
    semSignal(wsem);
    semWait(y);
    writecount--;
    if (writecount == 0) {
      semSignal(rsem);
    }
    semSignal(y);
  }
}

void main() {
  readcount = 0;
  writecount = 0;
  parbegin(reader, writer);
}

参考

[1] William Stallings, 操作系统——精髓与设计原理(8th), 2017.

 posted on   x-yun  阅读(128)  评论(0编辑  收藏  举报
编辑推荐:
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
阅读排行:
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
点击右上角即可分享
微信分享提示