Skip to the content.

Abstractions 1: Threads and Processes :A quick, programmer’s viewpoint

1. Recall && Intro

1.1 Translation throuth Page Table

给出一个简要的抽象图,后续课程会有更多细节:

image-20230808203938188

虚拟地址由CPU给出,Translation Map实际上是MMU(硬件:内存管理单元)使用Page Table对CPU给出的虚拟地址进行翻译,从而得到实际的物理内存地址。

这个机制可以确保不同进程(线程)在正常运行情况下永远不会访问到别的进程(线程)中的数据和内容,同时可以看到在物理地址空间中同样也有内核态下的一些线程(进程)在运行。这种机制确保了线程(进程)之间不会访问到对方的数据,进行了有效的隔离。

用户态进程(线程)无法访问到所有的物理地址空间,而内核态进程(线程)可以访问到所有的物理地址空间。

而page table就是确保上述保护与隔离的关键部分,所以需要确保用户态无法修改使用的page table。

1.2 Goals for today

  1. 什么是线程

    线程(thread)是程序中独立执行的最小单位。一个进程(process)可以有一个或多个线程,它们共享进程的内存空间和资源。线程独立于其他线程执行,并具有各自的程序计数器、堆栈和寄存器。线程并不是进程,进程是操作系统分配资源和管理的一个基本单位,一个进程可以包含多个线程。

  2. 为什么线程有用(动机)

    线程的使用可以提高程序的性能和响应能力,主要体现在以下几点:

    a. 并发执行:线程允许程序在多个任务之间并发执行,从而提高任务处理的效率。

    b. 更好地利用多核处理器:在多核处理器的计算机上,多线程可以充分利用处理器资源,显著提高程序的性能。

    c. 响应性:在某些场景下,如 GUI 应用程序,多线程可以确保程序始终对用户输入作出响应,即使在处理其他任务时也不会出现界面冻结。

    d. 资源共享:线程之间可以共享进程的内存和资源,降低了通信成本和资源管理的复杂性。

  3. 如何编写使用线程的程序

    编写多线程程序通常需要以下几个步骤:

    a. 确定并发执行的任务,将其拆分成互相独立的函数或方法。

    b. 根据编程语言和库的线程模型,创建线程来执行这些任务。例如,使用 C++ 11 标准中的 std::thread,Python 中的 threading 库,或 Java 中的 Thread 类。

    c. 处理线程之间的通信和同步,如使用锁、信号量或其他同步原语来确保数据的一致性和避免竞争条件。

    d. 关注线程的创建、终止和管理,确保程序正确终止并避免资源泄漏。

  4. 线程的替代方案

    虽然线程在解决并发问题方面具有优势,但有时也可考虑其他并发策略,如:

    a. 多进程:将任务分配到多个独立的进程中并行执行,适用于需要严格的内存资源隔离的场景。

    b. 协程或绿色线程:轻量级的用户空间调度实体,它们能实现类似多线程的并发效果,但具有较低调度开销和内存需求。例如,在 Python 中,可以使用 asyncio

    c. 事件驱动编程:基于事件回调机制的编程风格,适合处理高度异步的 I/O 密集型任务,如 Node.js。

2. Threads

2.1 What Threads Are

之前的定义:单一唯一的执行上下文

线程提供了一个抽象:一个单独的执行序列,代表单独可调度的任务

线程是实现并发(重叠执行)的机制

保护是一个正交概念

在操作系统(OS)中,”Protection is an orthogonal concept”(保护是一个正交概念)意味着保护与其他操作系统概念相互独立。正交(orthogonal)在此背景下表示互不影响。换句话说,保护概念可以独立于其他功能和概念实施,如线程、进程或内存管理等。

保护概念被认为是正交的,因为您可以改变保护策略,而不会对其他操作系统组件产生显著影响。例如,您可以更改资源访问权限或限制进程间通信,而不会影响线程调度、内存分配或文件系统等其他组件的运作。 保护的正交性可以促进操作系统设计的模块化和灵活性,允许开发人员独立调整或优化各个组件,而不会导致其他组件之间的复杂依赖。在实际应用中,正交性是一种理想化设计目标,实际系统可能需要进行一定程度的权衡,以确保功能和性能之间的平衡。

2.2 Motivation for threads(Multiple Things At Once-MTAO)

操作系统必须同时处理多种事务 (MTAO)

网络服务器需要处理 MTAO

并行程序必须处理 MTAO

具有用户界面的程序通常需要处理 MTAO

网络和磁盘受限的程序必须处理 MTAO

线程允许处理 MTAO(多任务一次性处理)

2.3 Multiprocessing vs. Multiprogramming

一些定义:

什么是同时运行两个线程(concurrently)?

2.4 Threads Mask I/O Latency

一个线程处于以下三种状态之一:

如果一个线程在等待 I/O(输入/输出)操作完成,则操作系统将其标记为阻塞态(BLOCKED)。

一旦 I/O 操作最终完成,操作系统会将其标记为就绪态(READY)。

后面的lecture中有专门的章节讲这一块内容。

2.5 Multithreaded Programs

您知道如何编译 C 程序并运行可执行文件

最初,这个新进程在它自己的地址空间中有一个线程

问:我们如何创建一个多线程进程?

答:一旦进程启动,它会发出系统调用来创建新线程

2.6 OS Library-The bridge to System calls

OS lib是供用户使用syscalls的一个桥梁:

image-20230810171518129

用户程序并不能直接使用syscalls的接口,用户程序通过使用OS library,之后由OS library中的接口调用syscalls,后面我们会学习到这个OS lib。(即OS Library中有很多API,调用这些API可以间接的使用syscalls的服务)

image-20230810171940259

操作系统库(OS lib,即上图中的libc)是一组提供程序访问操作系统功能的 API(应用程序编程接口)。系统调用(Syscalls)是操作系统库中的预定义功能,它们充当应用程序与操作系统之间的桥梁,允许程序请求 OS 执行特定任务(例如,创建文件、管理内存、创建新线程等)。 当程序需要执行一项涉及底层操作系统管理的任务时,它将通过操作系统库发出相应的系统调用。操作系统会响应这个请求,执行相应的任务并将结果返回给程序。这种机制使得程序能够以安全、有效的方式与操作系统进行交互,同时保护不同程序和系统资源之间的有效隔离。

3.OS Library And System Call

3.1 OS Library API for Threads: pthreads

在C语言中,pthread 前面的 p 表示 POSIX,它是 Portable Operating System Interface 的缩写。这表明这个库是符合 POSIX 规范的库。POSIX 是IEEE为要在各种UNIX操作系统上运行软件而制定的一系列标准。

pthread_create函数用于创建一个新线程,原型如下:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);

当调用 pthread_create 函数时,它会创建一个新的线程,并在该线程中执行 start_routine 函数,传递给start_routine的参数为 arg。当 start_routine 函数返回时,线程将隐式调用 pthread_exit 函数并退出。

pthread_exit函数用于终止一个线程,并将返回值传递给value_ptr

void pthread_exit(void *value_ptr);

当调用 pthread_exit 函数时,它会终止当前线程,并将 value_ptr 指向的值作为线程退出时的返回值。如果其他线程调用了 pthread_join 函数来等待这个线程结束,那么它将获得这个返回值。

pthread_join函数用于在调用线程中等待目标线程的终止。

int pthread_join(pthread_t thread, void **value_ptr);

pthread_join 是 POSIX 线程库中的一个函数,它会挂起调用线程的执行,直到目标线程终止。如果 value_ptr 不为 NULL,则被终止的线程传递给 pthread_exit() 的值将在 value_ptr 引用的位置可用。

3.2 New Idea: Fork-Join Pattern

image-20230811160818274

Fork-Join 模式是一种常见的并行编程模式,可用于将大型任务划分为多个较小的子任务,这些子任务可以由多个线程并发执行。主线程创建一组子线程,将参数传递给它们以进行工作。这些子线程然后并发执行它们分配的任务。一旦所有子线程完成了它们的任务,主线程与它们连接,收集它们工作的结果。

这种模式可以通过利用多个处理器或核心来提高计算密集型任务的性能。它常用于并行算法,并可以使用各种并行编程框架实现,例如 OpenMP、Cilk 和 Intel Threading Building Blocks (TBB)。

3.3 Thread State

在进程的地址空间中所有线程共享的状态:

每个线程的“私有”状态:

执行栈:

image-20230811163312822

堆内存可以在多线程之间共享的原因如下:

  1. 全局和动态内存分配:堆内存区域在进程范围内用于全局变量和动态内存分配。因此,当多线程需要处理使用相同数据结构或共享资源时,它们可以在堆上分配和存储这些数据。线程间共享堆内存意味着它们可以同时访问和操作同一块内存区域,从而实现线程间的通信和资源共享。
  2. 显式控制内存管理:堆内存的分配和释放需要由程序员显式控制。这意味着,当一个线程在堆上分配内存并与其他线程共享时,开发人员需要确保合适的内存管理策略,避免潜在的内存问题(如泄漏、竞争条件等)。这样的机制提供了更精细控制的共享内存机制。
  3. 灵活的内存分配:堆内存提供了比栈内存更灵活的内存分配方式。堆内存可以按需分配和释放,而不受栈上分配和函数调用顺序的限制。这种灵活性使线程能够方便地共享大小可变的数据结构(如链表、树、动态数组等)。

因此,堆内存在进程内线程间共享,因为它提供了一种方便、灵活的方式来存储全局变量和动态分配的资源。它允许线程相互访问和交流这些数据和资源,从而实现更有效的并发程序。然而,需要注意的是,在操作共享堆内存时,可能会出现竞争条件和其他并发问题。因此,在使用共享堆内存时,线程同步和资源保护机制非常重要。

3.4 Memory Layout with Two Threads

当一个进程中包含多个线程时,我们需要多组寄存器,多个栈。由于多个进程共享该进程内部的地址空间、heap、全局变量,所以有一些必要的情况我们不得不考虑:

image-20230811164623162

  1. 如何相对定位栈? 在多线程环境中,每个线程需要一个独立的执行栈。通常,线程栈在虚拟地址空间中位于非相邻的区域。操作系统分配线程栈时需要确保栈之间的相对位置不会导致意外的栈溢出或冲突。

  2. 栈的最大大小应该选多少? 栈的大小取决于具体应用和实现。如果线程的栈太小,可能会导致栈溢出。如果线程的栈太大,可能会导致内存浪费。一般来说,程序设计者需要仔细评估程序的需求并根据调用深度、局部变量数量等因素为每个线程分配合适的栈大小。

  3. 如果线程违反了栈大小限制会发生什么? 如果线程违反了栈大小限制,通常会导致栈溢出错误。这可能导致程序崩溃、数据损坏、内存泄漏或其他未定义行为。

  4. 如何捕获栈溢出违规行为? 可以采取以下方法检测栈溢出:

    • 设置保护页面:在栈的边界设置只读的保护页面,当线程试图访问这些页面时,操作系统将触发访问违规错误。
    • 在编译时检查:某些编译器可以在编译时分析栈使用情况, 并给出警告,如果检测到栈溢出的风险。
    • 使用静态代码分析和动态分析工具:这些工具可以评估程序的栈使用情况,帮助查找潜在的栈溢出问题。

总之,处理多线程时需要关注栈的定位、大小限制和检测栈溢出问题。确保每个线程的栈合适大小对于避免程序错误和实现稳定、可预测的运行至关重要。

4. Interleaving and Non-determinism

OS Dispatcher/Scheduler在调度线程(进程)执行时充满了不确定性,一般而言线程(进程)是交替去占用处理器的,下面将会用大量的篇幅阐述这个观点。

4.1 Correctness with Concurrent Threads

在具有并发线程的系统中保证正确性面临一些挑战:

非确定性:

独立线程:

协作线程:

4.2 Relevant Definitions

以下是关于线程同步和互斥的一些相关定义:

  1. 同步(Synchronization):线程之间的协调,通常涉及共享数据的访问和操作。
  2. 互斥(Mutual Exclusion):确保同一时间内只有一个线程执行特定任务(一个线程在运行时排斥其他线程)。互斥是同步的一种类型。
  3. 临界区(Critical Section):一段同一时间只能被一个线程执行的代码。是互斥实现的结果。
  4. 锁(Lock):一种只能同时被一个线程持有的对象。用于实现互斥。

4.2.1 Locks

锁提供了两种原子操作来实现线程之间的互斥访问:

  1. Lock.acquire():等待锁变为空闲状态,然后将其标记为繁忙。 当此方法返回时,我们称调用线程持有该锁。
  2. Lock.release():将锁标记为空闲。此方法仅应由当前持有该锁的线程调用。 当此方法返回时,调用线程不再持有该锁。

锁的这两个原子操作能确保在线程间实现互斥访问,维护共享资源的完整性和安全性。

至于锁的具体实现,目前不需要深入了解!在课程的后续阶段,我们将深入探讨锁的实现方式及其底层原理。这将有助于更好地理解如何利用锁实现有效地线程同步和互斥。

4.3 OS Library Locks:pthreads

在POSIX线程库(Pthreads)中,用于创建和管理互斥锁(mutex)的函数如下:

  1. int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr): 初始化一个互斥锁。mutex参数是一个指向互斥锁对象的指针,attr参数用于设置互斥锁的属性。如果使用默认属性,可以将attr设为NULL。
  2. int pthread_mutex_lock(pthread_mutex_t *mutex);: 请求锁定互斥锁,实现对临界区的互斥访问。如果互斥锁已被其他线程锁定,则调用线程将阻塞,直到获取到互斥锁。
  3. int pthread_mutex_unlock(pthread_mutex_t *mutex);: 释放互斥锁。该函数应由当前持有互斥锁的线程调用。在调用此函数之后,其他线程可以尝试获取互斥锁。

这些函数可用于实现线程同步和互斥访问共享资源。请确保在您的代码中正确地使用这些函数以避免出现死锁或竞态条件等问题。

在作业1中,您将有机会使用这些函数来编写并发程序并实现线程同步。编写正确的多线程代码需要仔细考虑同步策略并合理使用互斥锁以确保数据的完整性和一致性。

5. Processes

5.1 Bootstrapping

引导(Bootstrapping)是操作系统启动的过程,在这过程中涉及到第一个进程的启动。如果进程是由其他进程创建的,那么第一个进程是如何启动的呢?

  1. 第一个进程由内核启动。内核是操作系统的核心部分,负责管理系统资源和进程。操作系统启动时,内核将在系统上创建第一个进程。
  2. 第一个进程通常在内核启动之前作为参数配置给内核。这个进程通常被称为“init”进程。
  3. 一旦“init”进程启动,系统上的所有其他进程都由其他进程创建。这意味着在“init”进程之后,进程之间的创建关系形成了一种层次结构或树状结构。

总之,操作系统启动过程中,内核会创建一个名为“init”的第一个进程,之后所有进程都是由其他进程创建。这样一来,整个系统的进程从一个起源点开始,形成层次结构,实现了操作系统的引导。

5.2 Process Management API

以下是操作系统(尤其是类Unix系统)中关于进程管理的一些常见函数:

  1. exit():终止一个进程。当进程完成其执行或需要被终止时,可以调用此函数来结束进程。
  2. fork():复制当前进程。此函数创建一个新进程,其内容与调用进程基本相同。新进程被称为子进程,而调用进程称为父进程。
  3. exec():更改当前进程运行的程序。此函数用于替换当前进程的地址空间,包括代码段、数据段和堆栈段,使其执行一个新的程序。
  4. wait():等待一个进程结束。通常,父进程会调用此函数来等待其子进程完成。在子进程完成之前,父进程将被阻塞。
  5. kill():向另一个进程发送信号(类似于中断的通知)。此函数可用于通知其他进程某些事件发生,例如终止、挂起或其他处理。
  6. sigaction():为信号设置处理程序。此函数允许进程在收到特定信号时调用自定义的处理函数。这有助于进程在特定情况下采取适当的操作。

5.2.1 pid.c

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
int main(int argc, char *argv[])
{
    /* get current processes PID */
    pid_t pid = getpid();
    printf("My pid: %d\n", pid);
    exit(0);
}

如果我们允许main函数返回而不调用exit(),操作系统(OS)库会为我们调用exit()。

在可执行文件的入口点实际上是操作系统库。操作系统库负责调用main函数并启动我们的程序。当main函数返回时,操作系统库会自动调用exit()函数,确保进程得到正确的终止。

在Project 0的init.c中,您将看到类似的处理方式。操作系统库会确保进程始终正确地结束,即使开发者在main函数中没有显式调用exit()。这为程序提供了更为稳定和可靠的运行环境。

5.3 Creating Processes-fork()

pid_t fork():复制当前进程。新进程具有不同的进程ID(pid),且新进程只包含一个线程。

fork()的返回值:pid_t类型(类似于整数)。

父进程和子进程中原始进程的状态是相同的!

通过调用fork()函数,可以创建新进程。在父进程和子进程中,fork()的返回值不同,因此可以根据返回值判断当前进程的身份(父进程还是子进程),并执行相应操作。需要注意的是,fork()函数会复制原始进程的状态到新创建的子进程中,所以在进行进程间通信和资源共享时要加以管理。

在调用 fork() 之后,子进程不会从程序的开头执行,而是从 fork() 被调用的位置继续执行(包括fork()本身,这意味着子进程也会执行一次fork())。在子进程中,fork() 的返回值为 0,表明当前正在运行的进程是子进程。在父进程中,fork() 返回子进程的进程 ID,该值是一个非零整数。

5.3.1 fork()的具体行为

fork()函数是操作系统中用于创建新进程的API,在Unix和类Unix系统(如Linux)中广泛使用。当调用fork()时,操作系统执行以下详细步骤:

  1. 分配并初始化新的进程描述符:操作系统初始化一个进程描述符(内核数据结构),用于存储新进程的相关信息。
  2. 分配新的进程ID(pid):操作系统为新创建的子进程分配一个唯一的进程ID(pid)。
  3. 复制父进程的内存空间:新进程的内存空间会成为父进程内存空间的副本。这包括代码段、数据段和堆栈段。父进程和子进程的内存空间相互独立,对一个进程的任何更改都不会影响另一个进程。虽然内存空间是分开的,但在现代操作系统上通常使用写时复制(copy-on-write,COW)策略以提高性能和节省内存资源。
  4. 复制父进程的文件描述符:子进程会继承父进程打开的文件描述符,这些文件描述符在子进程中引用相同的文件。如果子进程更改了文件的当前读/写位置,它也会影响父进程和其他引用相同文件的进程。
  5. 复制父进程的其他属性:子进程还会继承父进程的其他属性,如进程优先级、环境变量、资源限制等。
  6. 返回父子进程:在父进程中,fork()返回新创建的子进程的pid。而在子进程中,fork()返回0。这使得我们可以根据返回值来区分父子进程,进而实现不同的功能逻辑。

需要注意的是,尽管fork()会复制父进程的状态,但子进程通常会立即调用exec(或类似的函数)来更改其执行的程序,从而运行不同代码。fork()和exec()通常联合使用,以便并发地运行多个程序。

总之,fork()函数创建新进程时,会复制父进程的内存空间、文件描述符和其他属性,并为新进程分配唯一的pid。该函数还通过返回值为父子进程设定了不同的执行逻辑。

5.3.2 fork()-examples

int i;
pid_t cpid = fork();
if (cpid > 0) {
    for (i = 0; i < 10; i++) {
        printf("Parent: %d\n", i);
        // sleep(1);
	}
} else if (cpid == 0) {
    for (i = 0; i > 10; i‐‐) {
        printf("Child: %d\n", i);
        // sleep(1);
	}
} else { /* ERROR! */ }

当fork()创建子进程时,子进程将继承父进程的内存空间、已打开文件描述符等,但fork()会复制父进程的内存空间以便两个进程拥有独立的地址空间。这样,尽管子进程看似继承了父进程的数据,但实际上它只是获得了父进程数据的一份拷贝。因此,在子进程中修改i的值不会影响父进程中i的值,反之亦然。这种内存隔离特性确保了父子进程能够独立地修改和管理自己的变量和资源,而不会互相干扰。

但是输出时的顺序是不确定的,因为调度器不知道会在何时进行切换,所以父子进程的输出结果会交错出现。

5.3.3 parent terminated

当一个父进程终止时,它的所有子进程不会立即被终止。然而,作为父进程终止的副作用,子进程将成为孤儿进程,并被init进程(通常是进程ID为1的进程)接管。init进程负责在系统中收养孤儿进程,并在它们终止后处理它们的结束状态。

需要注意的是,此时子进程将失去与原父进程的通信和关联。如果子进程依赖于父进程的某些资源或输入,那么子进程的行为可能会受到影响。 在某些情况下,可能需要确保父进程终止时,它的子进程也被终止。为了做到这一点,可以在父进程中编写信号处理程序,以便在接收到信号时(如SIGTERM或SIGINT)通知所有子进程终止。父进程可以向子进程发送SIGTERM信号,请求它们优雅地终止,或者发送SIGKILL信号,强制它们立即终止。不过,请注意,强制终止子进程可能导致资源泄漏或数据损坏。在可能的情况下,应该允许子进程优雅地退出。

5.4 Running Another Program-exec()

多线程与多进程创建方法的比较:

  1. 对于多线程,我们可以调用pthread_create函数创建一个新线程,该线程执行单独的函数。
  2. 对于多进程,等效操作是创建一个新进程,但是执行不同的程序。

要实现多进程并发执行不同程序,我们可以使用以下方法:

  1. 调用fork()函数来创建一个新进程。新进程将复制父进程的内存空间和其他状态,继承执行相同的程序代码。
  2. 在子进程中,立即调用exec系列函数(如execl(), execv(), execle()等),用新程序替换子进程的代码、数据和堆栈段。子进程将开始运行新程序。
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // 子进程
        execl("/bin/ls", "ls", "-l", (char *)NULL);
        perror("execl() failure"); // exec函数仅在出错时返回
        return 1; // 或exit(1),表示出错,出错返回非0值
    } else if (pid > 0) {
        // 父进程
        printf("Parent process, child process pid: %d\n", pid);
    } else {
        // fork()出错
        perror("fork() failure");
        return 1;
    }

    return 0;
}

在此示例中,父进程首先使用fork()创建一个子进程,在子进程中调用execl()执行/bin/ls程序。子进程将运行新程序,而父进程继续执行其原有代码。通过fork()和exec()的组合,实现了多进程并发执行不同程序的目标。

image-20230813163718001

5.5 parent waits for child to finish-wait()

int status;
pid_t tcpid;

cpid = fork();
if (cpid > 0) {               /* Parent Process */
    mypid = getpid();
    printf("[%d] parent of [%d]\n", mypid, cpid);
    tcpid = wait(&status);
    printf("[%d] bye %d(%d)\n", mypid, tcpid, status);
} else if (cpid == 0) {      /* Child Process */
    mypid = getpid();
    printf("[%d] child\n", mypid);
    exit(42);
}

注意父进程等待的是子进程这个进程,而不是子进程中运行的程序。

5.6 kill() and sigation()

这段代码是一个简单的C程序,用于捕获(捕捉)SIGINT信号(通常是用户通过键盘按下Ctrl+C发送的中断):

#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>

// 定义信号处理函数。当接收到SIGINT信号时,此函数会被调用。
void signal_callback_handler(int signum) {
    printf(Caught signal!\n);
    exit(1);
}

int main() {
    struct sigaction sa;
    // flags和
    sa.sa_flags = 0;  // 设置标志为0(无额外选项)
    sigemptyset(&sa.sa_mask);  // 初始化信号集,将其设为空集(不阻塞其他信号)
    sa.sa_handler = signal_callback_handler;  // 设置信号处理函数
    // 注册SIGINT信号的处理函数。当接收到SIGINT信号时,signal_callback_handler函数会被调用。
    sigaction(SIGINT, &sa, NULL);
    while (1) {} // 无限循环,等待接收信号。当接收到SIGINT信号时,程序将跳到信号处理函数并执行。
}

问:如果进程接收到SIGINT信号,但没有注册信号处理程序,会发生什么? 答:进程会被终止!

每个信号都有一个由系统定义的默认处理程序。对于SIGINT信号,默认操作通常是终止进程。如果您希望在接收到SIGINT信号时自定义处理操作(例如进行资源清理或保存数据等),则需要注册自定义的信号处理程序。如果没有注册自定义处理程序,系统将使用默认处理程序来处理信号。在SIGINT信号的情况下,默认操作是进程会被终止。

5.7 Common POSIX Signals

以下是一些常见的信号以及它们的作用:

  1. SIGINT:Ctrl+C,通常由用户使用键盘触发,用于终止进程。
  2. SIGTERM:默认为shell命令kill发送的信号,用于优雅地终止进程。进程可以捕获并处理该信号,执行一些清理工作或其他操作。
  3. SIGTSTP:Ctrl+Z,通常由用户通过键盘触发,默认操作是暂停(停止)进程。进程可随后通过SIGCONT信号继续执行。
  4. SIGKILL, SIGSTOP:分别用于终止和暂停进程。这两个信号具有特殊性质,它们不能被更改为自定义处理程序。

SIGKILL和SIGSTOP信号不能通过sigaction更改,原因在于它们的目的与正常的信号处理机制有所不同。SIGKILL和SIGSTOP信号被设计为绝对控制信号,目的是确保一个进程可以在需要时被终止或暂停,无论该进程当前是否响应其他信号。

如果允许进程使用sigaction自定义SIGKILL和SIGSTOP的处理程序,可能导致进程忽略这些信号,从而使得操作系统无法终止或暂停无响应的进程。为确保在必要时可以对进程执行控制操作,SIGKILL和SIGSTOP信号被设计为不能被修改或忽略。

5.8 Shell

一个shell是一个作业控制系统,允许程序员创建和管理一组程序来完成某个任务。

在Homework 2中,您将构建自己的shell,其中将涉及以下内容:

  1. 使用fork()和exec()系统调用创建新进程。通过调用fork(),您将创建新的子进程,子进程将继承父进程的状态。在子进程中调用exec()系列函数(如execl(), execv(), execle()等),用新程序替换子进程的代码、数据和堆栈段。子进程将开始运行新程序,从而实现并发地运行多个程序。
  2. 使用文件I/O系统调用将这些进程链接在一起。在下一节中,您将学习关于文件I/O系统调用的内容。通过这些系统调用,您将能够管理文件描述符(例如,重定向输入和输出流),实现进程之间的通信和协作。这些功能对于构建一个有效的shell至关重要。

6. Summary

6.1 Process vs. Thread APIs

为什么进程使用fork()和exec()系统调用,而线程只需要pthread_create()函数?

  1. 方便性:使用fork()且不调用exec()的情况,允许我们在同一个可执行文件中放置父进程和子进程的代码,而不需要将它们分开。这种方法简化了代码结构,并通过父子进程共享相同的代码实现了资源有效利用。
  2. 可编程控制:在子进程调用exec()之前,我们可以执行其他代码,以便以编程方式控制子进程的状态。使用fork()和exec()执行进程的创建和编程控制提供了更大的灵活性。在下一次关于文件I/O的讨论中,我们将看到这一点。

相比之下,线程具有相同的地址空间以及共享资源,因此在创建时使用单个pthread_create()函数能够简化线程创建过程,同时确保所需资源得到合理管理。

值得注意的是,Windows系统使用CreateProcess()函数来创建新进程,而不是fork()。CreateProcess()也能正常工作,但其接口相对更复杂,这是因为它为进程的创建、属性等提供了更详细的控制。然而,在概念上,与fork()和exec()组合类似,CreateProcess()同样用于创建和执行新进程。

6.2 Threads vs. Processes(如何决定用哪个)

如果我们有两个任务需要并发运行,我们应该将它们在单独的线程中执行还是在单独的进程中执行?

这取决于我们希望这些任务之间有多少隔离:

因此,在决定是否在单独的线程或进程中运行任务时,我们需要根据任务之间的隔离需求和资源共享需求来权衡。

线程适合于需要共享资源并具有较低隔离需求的任务,而进程更适合于需要强隔离并且资源共享成本可接受的任务。

6.3 PCB vs. TCB

进程控制块(PCB)和线程控制块(TCB)是操作系统用于管理和追踪进程和线程的关键数据结构。它们分别包含与进程和线程相关的信息,如状态、资源和调度。在这里,我们将分别从不同的方面详细解释 PCB 和 TCB。

进程控制块(PCB):进程控制块是操作系统用于表示和跟踪每个进程的数据结构。每个进程都有一个与之关联的唯一 PCB。PCB 包含以下信息:

线程控制块(TCB):线程控制块是表示和跟踪每个线程的数据结构。与同一进程共享相同资源(如内存空间和打开的文件)的线程具有相同的 TCB。TCB 包含以下信息:

总之,PCB 和 TCB 是用于描述和管理进程和线程的关键数据结构。它们分别包含有关进程和线程状态、资源和调度的信息,并存储在内核部分的内存空间中以确保系统的稳定性、安全性和效率。

6.4 Conclusion

线程是操作系统并发性的基本单元:

进程由一个地址空间中的一个或多个线程组成:

操作系统库的作用:

在现代操作系统中,任何在内核之外运行的程序都会在一个进程中执行。 进程为程序提供了独立的执行环境,包括一个独立的地址空间,分隔的资源(如文件描述符),以及进程特定的控制结构。这种隔离性保证了程序之间可以并发运行,而不对彼此造成意外干扰。当一个程序出现问题(如崩溃)时,操作系统能够在进程之间隔离问题,防止其他程序受到影响。