操作系统(一) —— 进程线程模型

进程线程模型

  • 线程是调度的基本单位,进程是资源分配的基本单位

多线程模型

1. 线程创建和结束
  • 背景知识:
    在一个文件内的多个函数通常都是按照main函数中出现的顺序来执行,但是在分时系统下,我们可以让每个函数都作为一个逻辑流并发执行,最简单的方式就是采用多线程策略。
    在main函数中调用多线程接口创建线程,每个线程对应特定的函数(操作),这样就可以不按照main函数中各个函数出现的顺序来执行,避免了忙等的情况。线程基本操作的接口如下。

  • 相关接口:

    • 创建线程:int pthread_create(pthread_t *pthread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *agr);

      创建一个新线程,pthread和start_routine不可或缺,分别用于标识线程和执行体入口,其他可以填NULL。

      • pthread:用来返回线程的tid,*pthread值即为tid,类型pthread_t == unsigned long int。
      • attr:指向线程属性结构体的指针,用于改变所创线程的属性,填NULL使用默认值。
      • start_routine:线程执行函数的首地址,传入函数指针。
      • arg:通过地址传递来传递函数参数,这里是无符号类型指针,可以传任意类型变量的地址,在被传入函数中先强制类型转换成所需类型即可。
    • 获得线程ID:pthread_t pthread_self();

      调用时,会打印线程ID。

    • 等待线程结束:int pthread_join(pthread_t tid, void** retval);

      主线程调用,等待子线程退出并回收其资源,类似于进程中wait/waitpid回收僵尸进程,调用pthread_join的线程会被阻塞。

      • tid:创建线程时通过指针得到tid值。
      • retval:指向返回值的指针。
    • 结束线程:pthread_exit(void *retval);

      子线程执行,用来结束当前线程并通过retval传递返回值,该返回值可通过pthread_join获得。

      • retval:同上。
    • 分离线程:int pthread_detach(pthread_t tid);

      主线程、子线程均可调用。主线程中pthread_detach(tid),子线程中pthread_detach(pthread_self()),调用后和主线程分离,子线程结束时自己立即回收资源。

      • tid:同上。
2. 线程同步
  • Linux下提供了多种方式来处理线程同步,最常用的是互斥锁、条件变量和信号量。

  • 互斥锁(mutex)
      锁机制是同一时刻只允许一个线程执行一个关键部分的代码。

    • 初始化锁:int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutex_attr_t *mutexattr);

      其中参数mutex为指向需要初始化的互斥锁的指针,参数mutexattr用于指定锁的属性,如果为NULL则使用缺省属性。

    • 阻塞加锁:int pthread_mutex_lock(pthread_mutex *mutex);

      其中参数mutex为指向需要获取的互斥锁的指针

    • 非阻塞加锁:int pthread_mutex_trylock( pthread_mutex_t *mutex);

      该函数语义与 pthread_mutex_lock() 类似,不同的是在锁已经被占据时返回 EBUSY 而不是挂起等待。

    • 解锁(要求锁是lock状态,并且由加锁线程解锁):int pthread_mutex_unlock(pthread_mutex *mutex);

      其中参数mutex为指向需要释放的互斥锁的指针

    • 销毁锁(此时锁必需是unlock状态,否则返回EBUSY):int pthread_mutex_destroy(pthread_mutex *mutex);

      其中参数mutex为指向需要释放的互斥锁的指针

  • 条件变量(cond)
      条件变量是利用线程间共享全局变量进行同步的一种机制。条件变量上的基本操作有:触发条件(当条件变为 true 时);等待条件,挂起线程直到其他线程触发条件。

    • 初始化条件变量:int pthread_cond_init(pthread_cond_t *cond,pthread_condattr_t *cond_attr);

    其中参数cond为指向需要初始化的条件变量的指针,尽管POSIX标准中为条件变量定义了属性,但在Linux中没有实现,因此cond_attr值通常为NULL,且被忽略。

    • 有两个等待函数:
      (1)无条件等待:int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex);

      (2)计时等待:int pthread_cond_timewait(pthread_cond_t *cond,pthread_mutex *mutex,const timespec *abstime);

      • 如果在给定时刻前条件没有满足,则返回ETIMEOUT,结束等待,其中abstime以与time()系统调用相同意义的绝对时间形式出现,0表示格林尼治时间1970年1月1日0时0分0秒。
    • 无论哪种等待方式,都必须和一个互斥锁配合,以防止多个线程同时请求(用 pthread_cond_wait() 或 pthread_cond_timedwait() 请求)竞争条件(Race Condition)。
      mutex互斥锁必须是普通锁(PTHREAD_MUTEX_TIMED_NP)或者适应锁(PTHREAD_MUTEX_ADAPTIVE_NP),且在调用pthread_cond_wait()前必须由本线程加锁(pthread_mutex_lock()),
      而在更新条件等待队列以前,mutex保持锁定状态,并在线程挂起进入等待前解锁。在条件满足从而离开pthread_cond_wait()之前,mutex将被重新加锁,
      以与进入pthread_cond_wait()前的加锁动作对应。

    • 激发条件
      (1)激活一个等待该条件的线程(存在多个等待线程时按入队顺序激活其中一个):int pthread_cond_signal(pthread_cond_t *cond);

      其中参数cond为指向需要激活的条件变量的指针

      (2)激活所有等待线程:int pthread_cond_broadcast(pthread_cond_t *cond);

    • 销毁条件变量:int pthread_cond_destroy(pthread_cond_t *cond);

      只有在没有线程在该条件变量上等待的时候才能销毁这个条件变量,否则返回EBUSY

    • 说明:
        pthread_cond_wait 自动解锁互斥量(如同执行了pthread_unlock_mutex),并等待条件变量触发。这时线程挂起,不占用CPU时间,直到条件变量被触发(变量为ture)。
      在调用 pthread_cond_wait之前,应用程序必须加锁互斥量。pthread_cond_wait函数返回前,自动重新对互斥量加锁(如同执行了pthread_lock_mutex)。

      互斥量的解锁和在条件变量上挂起都是自动进行的。因此,在条件变量被触发前,如果所有的线程都要对互斥量加锁,这种机制可保证在线程加锁互斥量和进入等待条件变量期间,
      条件变量不被触发。条件变量要和互斥量相联结,以避免出现条件竞争——个线程预备等待一个条件变量,当它在真正进入等待之前,另一个线程恰好触发了该条件
      (条件满足信号有可能在测试条件和调用pthread_cond_wait函数(block)之间被发出,从而造成无限制的等待)。

      条件变量函数不是异步信号安全的,不应当在信号处理程序中进行调用。特别要注意,如果在信号处理程序中调用 pthread_cond_signal 或 pthread_cond_boardcast 函数,
      可能导致调用线程死锁

  • 信号量
    如同进程一样,线程也可以通过信号量来实现通信,虽然是轻量级的。线程使用的基本信号量函数有四个:
    #include <semaphore.h>

  • 初始化信号量:int sem_init (sem_t *sem , int pshared, unsigned int value);

    sem - 指定要初始化的信号量;
    pshared - 信号量 sem 的共享选项,linux只支持0,表示它是当前进程的局部信号量;
    value - 信号量 sem 的初始值。

    • 信号量值加1,给参数sem指定的信号量值加1:int sem_post(sem_t *sem);

      sem - 指定要加1的信号量;

    • 信号量值减1,给参数sem指定的信号量值减1:int sem_wait(sem_t *sem);

      如果sem所指的信号量的数值为0,函数将会等待直到有其它线程使它不再是0为止。

    • 销毁信号量,销毁指定的信号量:int sem_destroy(sem_t *sem);

  • 条件变量与互斥锁、信号量的区别
    1.互斥锁必须总是由给它上锁的线程解锁,信号量的挂出即不必由执行过它的等待操作的同一进程执行。一个线程可以等待某个给定信号灯,而另一个线程可以挂出该信号灯。

    2.互斥锁要么锁住,要么被解开(二值状态,类型二值信号量)。

    3.由于信号量有一个与之关联的状态(它的计数值),信号量挂出操作总是被记住。然而当向一个条件变量发送信号时,如果没有线程等待在该条件变量上,那么该信号将丢失。

    4.互斥锁是为了上锁而设计的,条件变量是为了等待而设计的,信号灯即可用于上锁,也可用于等待,因而可能导致更多的开销和更高的复杂性。

    5.互斥锁一般用于互斥,信号量一般用于同步,互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到

3. 线程互斥
  • 互斥锁(mutex)
    看 线程同步 中的互斥锁

  • 自旋锁(spin)

    • 定义自旋锁:pthread_spinlock_t spin;

    • 初始化自旋锁:int pthread_spin_init(pthread_spinlock_t *lock, int pshared);

    • 上锁
      (1)int pthread_spin_lock(pthread_spinlock_t *lock);

      (2)int pthread_spin_trylock(pthread_spinlock_t *lock);

    • 解锁:int pthread_spin_unlock(pthread_spinlock_t *lock);

    • 销毁锁:int pthread_spin_destroy(pthread_spinlock_t *lock);

  • 自旋锁和互斥锁的区别
    互斥锁是当阻塞在pthread_mutex_lock时,放弃CPU,好让别人使用CPU。自旋锁阻塞在pthread_spin_lock时,不会释放CPU,不断向cup询问可以用了不。

  • 读写锁

    • 读写锁的规则

      • 无锁时,允许写操作和读操作

      • 有读锁时,允许读操作,不允许写操作

      • 有写锁时,不允许写操作和读操作

    • 定义锁:pthread_rwlock_t lock;

    • 初始化:pthread_rwlock_init(&lock, NULL);

    • 读锁:pthread_rwlock_rdlock(&lock);

    • 写锁:pthread_rwlock_wrlock(&lock);

    • 解锁:pthread_rwlock_unlock(&lock);

    • 销毁锁:pthread_rwlock_destroy(&lock);

多进程模型

  • 每一个进程是资源分配的基本单位。进程结构由以下几个部分组成:代码段、堆栈段、数据段。代码段是静态的二进制代码,多个程序可以共享。
    实际上在父进程创建子进程之后,父、子进程除了pid外,几乎所有的部分几乎一样,子进程创建时拷贝父进程PCB中大部分内容,而PCB的内容实际上是各种数据、代码的地址或索引表地址,
    所以复制了PCB中这些指针实际就等于获取了全部父进程可访问数据。所以简单来说,创建新进程需要复制整个PCB,之后操作系统将PCB添加到进程核心堆栈底部,这样就可以被操作系统感知和调度了。

  • 父、子进程共享全部数据,但并不是说他们就是对同一块数据进行操作,子进程在读写数据时会通过写时复制机制将公共的数据重新拷贝一份,
    之后在拷贝出的数据上进行操作。如果子进程想要运行自己的代码段,还可以通过调用execv()函数重新加载新的代码段,之后就和父进程独立开了。
    我们在shell中执行程序就是通过shell进程先fork()一个子进程再通过execv()重新加载新的代码段的过程。

1. 进程创建与结束
  • 背景知识:
    进程有两种创建方式,一种是操作系统创建的一种是父进程创建的。
    从计算机启动到终端执行程序的过程为:0号进程 -> 1号内核进程 -> 1号用户进程(init进程) -> getty进程 -> shell进程 -> 命令行执行进程。
    所以我们在命令行中通过 ./program执行可执行文件时,所有创建的进程都是shell进程的子进程,这也就是为什么shell一关闭,在shell中执行的进程都自动被关闭的原因。
    从shell进程到创建其他子进程需要通过以下接口。

  • 相关接口:

    • 创建进程(1):pid_t fork(void);

      返回值:出错返回-1;父进程中返回pid > 0;子进程中pid == 0

    • 创建进程(2):pid_t vfork(void);//与fork的区别在于:父进程要等子进程运行完成后才能运行且父子进程的数据是共享的(当子进程调用exit或exec时)

      返回值:出错返回-1;父进程中返回pid > 0;子进程中pid == 0

    • 结束进程:void exit(int status);

      • status是退出状态,保存在全局变量中S?,通常0表示正常退出。
    • 获得PID:pid_t getpid(void);

      返回调用者pid。

    • 获得父进程PID:pid_t getppid(void);

      返回父进程pid。

  • 其他补充:

    • 正常退出方式:exit()、_exit()、return(在main中)。

      exit()和_exit()区别:exit()是对_exit()的封装,都会终止进程并做相关收尾工作,最主要的区别是_exit()函数关闭全部描述符和清理函数后不会刷新流,
      但是exit()会在调用_exit()函数前刷新数据流。

      return和exit()区别:exit()是函数,但有参数,执行完之后控制权交给系统。return若是在调用函数中,执行完之后控制权交给调用进程,若是在main函数中,控制权交给系统。

    • 异常退出方式:abort()、终止信号。

2. 僵尸进程、孤儿进程
  • 背景知识:
    父进程在调用fork接口之后和子进程已经可以独立开,之后父进程和子进程就以未知的顺序向下执行(异步过程)。所以父进程和子进程都有可能先执行完。
    当父进程先结束,子进程此时就会变成孤儿进程,不过这种情况问题不大,孤儿进程会自动向上被init进程收养,init进程完成对状态收集工作。
    而且这种过继的方式也是守护进程能够实现的因素。
    如果子进程先结束,父进程并未调用wait或者waitpid获取进程状态信息,那么子进程描述符就会一直保存在系统中,这种进程称为僵尸进程。

  • 相关接口:

    • 回收进程(1):pid_t wait(int *status);

      一旦调用wait(),就会立即阻塞自己,wait()自动分析某个子进程是否已经退出,如果找到僵尸进程就会负责收集和销毁,如果没有找到就一直阻塞在这里。

      • status:指向子进程结束状态值。
    • 回收进程(2):pid_t waitpid(pid_t pid, int *status, int options);

      返回值:返回pid:返回收集的子进程id。返回-1:出错。返回0:没有被手机的子进程。

      • pid:子进程识别码,控制等待哪些子进程。

        1. pid < -1,等待进程组识别码为pid绝对值的任何进程。
        2. pid = -1,等待任何子进程。
        3. pid = 0,等待进程组识别码与目前进程相同的任何子进程。
        4. pid > 0,等待任何子进程识别码为pid的子进程。
      • status:指向返回码的指针。

      • options:选项决定父进程调用waitpid后的状态。

        1. options = WNOHANG,即使没有子进程退出也会立即返回。
        2. options = WUNYRACED,子进程进入暂停马上返回,但结束状态不予理会。
3. 守护进程
  • 背景知识:
    守护进程是脱离终端并在后台运行的进程,执行过程中信息不会显示在终端上并且也不会被终端发出的信号打断。

  • 操作步骤:

      - 创建子进程,父进程退出:fork() + if(pid > 0){exit(0);},使子进程称为孤儿进程被init进程收养。
    
      - 在子进程中创建新会话:setsid()。
    
      - 改变当前目录结构为根:chdir("/")。
    
      - 重设文件掩码:umask(0)。
    
      - 关闭文件描述符:for(int i = 0; i < 65535; ++i){close(i);}。
    
4. Linux进程控制
  • 进程地址空间(地址空间)
    虚拟存储器为每个进程提供了独占系统地址空间的假象。尽管每个进程地址空间内容不尽相同,但是他们的都有相似的结构。X86 Linux进程的地址空间底部是保留给用户程序的,包括文本、数据、堆、栈等,其中文本区和数据区是通过存储器映射方式将磁盘中可执行文件的相应段映射至虚拟存储器地址空间中。有一些"敏感"的地址需要注意下, 对于32位进程来说,代码段从0x08048000开始。从0xC0000000开始到0xFFFFFFFF是内核地址空间,通常情况下代码运行在用户态(使用0x00000000 ~ 0xC00000000的用户地址空间),当发生系统调用、进程切换等操作时CPU控制寄存器设置模式位,进入内核模式,在该状态(超级用户模式)下进程可以访问全部存储器位置和执行全部指令。也就说32位进程的地址空间都是4G,但用户态下只能访问低3G的地址空间,若要访问3G ~ 4G的地址空间则只有进入内核态才行。

    • 进程控制块(处理机)
      进程的调度实际就是内核选择相应的进程控制块,被选择的进程控制块中包含了一个进程基本的信息。

    • 上下文切换
      内核管理所有进程控制块,而进程控制块记录了进程全部状态信息。每一次进程调度就是一次上下文切换,所谓的上下文本质上就是当前运行状态,
      主要包括通用寄存器、浮点寄存器、状态寄存器、程序计数器、用户栈和内核数据结构(页表、进程表、文件表)等。进程执行时刻,
      内核可以决定抢占当前进程并开始新的进程,这个过程由内核调度器完成,当调度器选择了某个进程时称为该进程被调度,该过程通过上下文切换来改变当前状态。
      一次完整的上下文切换通常是进程原先运行于用户态,之后因系统调用或时间片到切换到内核态执行内核指令,完成上下文切换后回到用户态,此时已经切换到进程B。

5. 进程的状态
  • 进程的三种基本状态
    进程在运行中不断地改变其运行状态。通常,一个运行进程必须具有以下三种基本状态。
    1)就绪状态:当进程已分配到除CPU以外的所有必要的资源,只有获得处理机便可以执行,这时的进程状态为就绪状态;

    2)执行状态:当进程已获得处理机,其程序正在处理机上执行。此时的进程状态称为执行状态;

    3)阻塞状态:正在执行的进程,由于等待某个事件发生而无法执行时,便放弃处理机而处于阻塞状态。

进程、线程、程序

  • 程序是指令和数据的有序集合,其本身没有任何运行的含义,是一个静态的概念。
    而进程则是在处理机上的一次执行过程,它是一个动态的概念。这个不难理解,其实进程是包含程序的,进程的执行离不开程序,进程中的文本区域就是代码区,也就是程序。

  • 进程和线程的主要差别在于它们是不同的操作系统资源管理方式。
    进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,
    而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,
    但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,
    但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。

  • 总结
    1) 简而言之,一个程序至少有一个进程,一个进程至少有一个线程。
    2) 线程的划分尺度小于进程,使得多线程程序的并发性高。3) 另外,进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。
    4) 线程在执行过程中与进程还是有区别的。每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
    5) 从逻辑角度来看,多线程的意义在于一个应用程序中,有多个执行部分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来实现进程的调度和管理以及资源分配。这就是进程和线程的重要区别。

  • 线程和进程在使用上各有优缺点:线程执行开销小,但不利于资源的管理和保护;而进程正相反。同时,线程适合于在SMP(多核处理机)机器上运行,而进程则可以跨机器迁移。

猜你喜欢

转载自blog.csdn.net/weixin_38337616/article/details/88911560