美文网首页
明明白白——线程与进程

明明白白——线程与进程

作者: 陈星空 | 来源:发表于2020-03-13 22:32 被阅读0次

    著名面试问题

    进程和线程的区别

    进程切换分为两步:
    1)切换页目录以使用新的地址空间(虚拟内存切换相关,代码段,数据段,堆)
    2)切换内核栈,硬件上下文
    线程是调度的基本单位,进程是资源分配的最小单位。线程共享所属进程的虚拟空间,所以线程切换时不涉及到虚拟空间相关的切换,而虚拟空间切换又比较耗时。所以进程切换的开销比线程大。
    1 )进程是一段程序代码在计算机中的动态运行状态,是系统资源分配的单位(包括CPU,内存,寄存器等),也是资源调度的单位,一个进程由一个程序以及与它相关的状态信息所组成,在计算机中一般有三种状态就绪,等待,执行,只有进程无法实现真正的并行,每个进程有自己独立的逻辑地址空间;
    2 )线程是进程中运行的实体,是轻量型进程,是CPU资源分配的单位,是处理器调度的最小单位,运行同一进程的线程具有相同地址空间(线程通信和进程通信的相关问题下面有提到),线程对象包含一个程序计数器(负责处理在下一次线程获取处理器时间时要执行的指令),一组寄存器(储存线程正在操控的变量值),一个栈(储存与函数调用和参数相关的数据)等;
    3 )相对进程而言,线程的创建和管理的开销要小得多,在进程内创建多个线程可以提高系统的并行处理能力
    4 )每个进程都至少有一个线程执行,一个进程可以交给多个线程执行,一个程序也可以对应多个进程

    多线程

    引入:
    一个栈中只有最下方的帧可被读写,相应的,也只有该帧对应的那个函数被激活,处于工作状态,。为了实现多线程必须绕开栈的限制。为此,在创建一个新的线程时,需要为这个线程创建一个新的栈,每个栈对应一个线程。当某个栈执行到全部弹出时,对应线程完成任务,并结束。所以,多线程的进程在内存中有多个栈,多个栈之间以一定的空白区域隔开,以备栈的增长。每个线程可调用自己栈最下方的镇中的参数和变量,并与其他线程共享内存中text、heap和global data区域。
    需要注意,由于同一个线程空间中存在多个栈,任何一个空白区域被填满都会导致栈溢出。

    线程的创建

    创建线程的函数
    #include <pthread.h> //需要包含的头文件
    int pthread_create(pthread_t *thread, 
                    const pthread_attr_t *attr, 
    void *(*start_routine) (void*), void *arg); 
    

    pthread_t的定义是typedef unsigned long int pthread_t,所以这是一个无符号的长整型。
    参数1:指向线程标识符的指针;
    参数2:设置线程属性;
    参数3:线程运行函数的起始地址;
    参数4:运行函数的参数。

    等待一个线程的结束
    int pthread_join(pthread_t thread, void **retval);
    

    这个函数是一个线程阻塞的函数,调用它的函数将一直等待到被等待的线程结束为止,当函数返回时,被等待线程的资源被收回。
    参数1:被等待的线程标识符;
    参数2:用户定义的指针;

    结束一个线程
    void pthread_exit(void *retval);
    

    一个线程的结束有两种途径:
    1)函数已经结束,调用它的线程也就结束了;
    2)通过函数pthread_exit来实现。
    唯一的参数是函数的返回代码。

    pthread_join和pthread_exit的区别
    1)pthread_join一般是主线程调用,用来等待子线程退出,因为是等待,所以是阻塞的,一般主线程回一次添加所有他创建的子线程;
    2)pthread_exit一般是子线程调用,用来结束当前线程;
    3)子线程可以通过pthread_exit传递一个返回值,而主线程通过pthread_join获得该返回值。从而判断该线程的退出正常与否。

    可以通过调用pthread_self函数获得线程id
    printf("thread id in pthread=%lu\n", pthread_self());
    

    线程的属性

    image.png
    image.png
    image.png

    将线程设置为结束状态分离后,线程的结束状态将不能被进程中的其他线程得到,同时保存线程结束状态的存储区域也将变得不能应用。

    线程的分离状态(detach)和结合状态(join):
    在任意一个时间点上,一个线程是可结合的,或者是可分离的。一个可结合的线程是可以被其他进程回收资源或者杀死的,在其他线程被回收之前,他的存储器资源(如栈)是不会被回收的。一个可分离的线程是不可以被其他线程回收资源或者杀死的,他的存储器资源在他终止的时候可以由系统自动释放。
    线程以正常状态启动还是以分离状态启动最根本的出发点是系统是否需要知道线程的终止状态;
    这两种状态的区别是:
    1)正常状态:可以由其他线程终止,回收资源。(可以看成有人等,有人陪)
    2)分离状态:不能被其他线程终止,存储资源在它终止时由系统自动回收释放。(没人等,没人陪,自生自灭,死后回归大自然)

    多线程同步
    同步是指在一定的时间内只允许某一个线程访问某个资源。而在此时间内,不允许其他的线程访问该资源。可以通过互斥锁(mutex),条件变量(condition variable),读写锁(reader-write lock)和信号量(semphore)来同步资源。

    互斥锁
    一个特殊的变量,他有锁上和打开两个状态,互斥锁一般被设置成全局变量。打开的互斥锁可以由某个线程获得。一旦获得,这个互斥锁会锁上,此后只有该线程有权打开,其他想要或的互斥锁的线程,会等待直到互斥锁再次打开的时候。

    image.png

    条件变量
    有这样一种情况:如果线程正在等待共享数据内某个条件出现,那么线程就要不停加锁解锁,检查条件是否发生,这十分浪费时间,而且效率低。这种情况可以使用条件变量;
    条件变量的作用是:当线程在等待满足某些条件时,是线程进入睡眠状态,一旦条件满足就唤醒因等待满足特定条件而睡眠的线程。
    条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补互斥锁的不足,常和互斥锁一起使用。使用时,条件变量阻塞一个线程,条件不满足不加锁,一旦其他的某个线程改变了条件变量,唤醒被阻塞的线程,这个唤醒的线程加锁,并测试条件是否满足。
    相关函数的使用
    1)创建:条件变量和互斥锁一样,都有静态动态的创建方法:
    静态方式使用PTHREAD_COND_INITIALIZER常量,函数原型是:
    pthread_cond_t cond=PTHREAD_COND_INITIALIZER
    动态方式则使用pthread_cond_init函数,函数原型为:
    int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr)
    2)注销:注销一个条件变量需要调用pthread_cond_destroy(),函数原型是:
    int pthread_cond_destroy(pthread_cond_t *cond)
    只有没有线程在该条件变量上等待的时候才能注销这个条件变量,否则返回EBUSY。linux下注销动作只包含是否有等待线程。
    3)等待:

    image.png
    4)激发:
    image.png

    读写锁
    读写锁的使用,使读和写分开,更大限度提高资源利用率,满足下面三个条件:
    1)当读写锁是写状态,所有试图加锁的线程都会被阻塞,不允许别人读,也不允许别人写;
    2)当读写锁是读状态,所有试图以读模式加锁的线程都被允许访问,以写模式加锁的将会被阻塞,允许读,不允许写;
    3)当读写锁是读状态时,如果有线程试图写,那么之后的读请求都会被阻塞,防止长期占用
    处理读者-写者问题的两种常见策略是强读者同步和强写者同步,前者总是给读者更高优先级,只要写者不写,读者就可以获得访问权限,后者往往将优先权先交给写者。
    读写锁相关的函数:
    1)初始化和销毁读写锁:

    image.png
    2)获取和释放读写锁:
    image.png

    信号量
    信号量和互斥锁的区别是:互斥锁只允许一个线程进入临界区,而信号量允许多个线程同时进入临界区。要使用信号量同步,需要包含头文件semaphore.h。信号量函数的名字都以sem_开头。
    信号量相关函数:
    1)sem_init函数。
    int sem_init(sem_t *sem, int pshared, unsigned int value);
    初始化sem指向的信号对象,设置它的共享选项,并给他一个初始的整数值。pshared控制信号量的类型,0表示这个信号量是当前进程的局部信号量,否则信号量可以在多个进程之间共享。value是sem的初始值,成功返回0,失败返回-1。
    2)sem_wait函数。
    该函数以原子操作的方式将信号量的值减1,原子操作值多个线程同时加一减一,互不影响。成功返回0,失败返回-1
    3)sem_post函数。
    该函数以原子操作的方式将信号量的值加1,成功返回0,失败返回-1
    4)sem_destroy函数。
    对用完的信号量进行清理,成功返回0,失败返回-1。

    多线程重入
    线程同步的方式都是为了解决“函数不可重入的问题”,所谓可重入的函数是指可以有多余一个任务并发使用,而不必担心数据错误的函数。可重入函数可以在任意时刻被中断,稍后继续运行,且不会丢失数据。

    可重入函数的特点
    1)不为连续的调用持有静态数据;
    2)不返回指向静态数据的指针;
    3)所有的数据都有函数的调用者提供;
    4)使用本地数据,或者制作全局数据的本地副本来保护全局数据;
    5)如果必须访问全局变量,要用互斥锁,信号量等来保护全局变量;
    6)绝不调用任何不可重用函数

    不可重入函数的特点
    1)函数中使用静态变量,无论是局部静态变量还是全局静态变量;
    2)函数中返回静态变量;
    3)函数中调用了不可重入函数;
    4)函数体内使用了静态的数据结构;
    5)函数体内调用了malloc(),或者free()函数;
    6)函数体内调用了其他标准IO函数。

    编写的多线程程序,通过定义宏_REENTRANT来告诉编译器需要可重入功能,这个宏的定义必须出现于程序中的任何#include语句之前。

    _PREENTRANT为我们做三件事,并且做得非常优雅
    1)他会对部分函数重新定义他们的可安全重入的版本,这些函数名字一般不会发生改变,只是会在函数名后面添加_r字符串;
    2)stdio.h原来以宏的形式实现的一些函数将变成可安全重入函数。
    3)error.h中定义的变量error现在将成为一个函数调用,他能够以一种安全的多线程方式来获得真正的errno的值。

    进程

    进程是程序(指令和数据)的真正运行实例。多个进程可与同一个程序相关联,而每个进程则是以同步或异步的方式独立运行的。
    进程一般由三部分组成:代码段、数据段和堆栈段。代码段适用于存放程序代码的数据,如果有数个进程运行同一个程序,那么他们可以共用一个代码段。而数据段则存放着程序的全局变量、常量和静态变量。堆栈段还包括了进程控制块(Process Control Block,PCB)PCB出于进程核心堆栈的底部,不需要额外空间。PCB是进程存在的唯一标识。通过PCB对进程进行管理和调度。PCB包括创建进程,执行程序,退出进程以及改变进程的优先级等。

    一般LInux下C++程序生成包含4个阶段:预编译,编译,汇编和链接。编译器g++经过预编译、编译、汇编三个步骤将源程序文件转换为目标文件。如果有多个目标文件或者程序使用了库函数,编译器要把目标文件和库链接起来,形成可执行程序。当程序执行时,操作系统将可执行程序复制到内存中。一般程序转换为进程分以下几个步骤:
    1)内核将程序读入内存,为程序分配内存空间;
    2)内核为该进程分配进程标识符(PID)和其他所需资源;
    3)内核为进程保存PID及相应的状态信息,把进程放到运行队列中等待执行,然后等待操作系统的调度程序调用。

    进程的创建与结束

    一种是由操作系统创建,一种是父进程创建。


    image.png

    注意:两个init的区别,前者是函数,后者是进程,区别如下:
    1)init()函数在内核态运行,是内核代码
    2)init进程是内核启动并运行的第一个用户进程,运行在用户态下;
    3)init()函数调用execve()从文件/etc/inittab中加载可执行程序init并执行,这个过程并没有使用调用do_fork(),因此两个进程都是1号进程。

    进程的创建——fork()函数
    Linux系统允许任何一个用户进程创建一个子进程,成功后接受系统调度,可以得到系统分配的资源,与父进程有同样的权利。Linux中除了0号进程(系统字举时由系统创建),其他都是由进程创建的,是一个进程树,根节点是0号进程。
    fork()函数原型:
    #include <unistd.h>
    pid_t fork(void);
    fork()函数不需要参数,返回值是一个PID,有以下三种情况:
    1)对于父进程,fork()函数返回新创建的子进程的ID;
    2)对于子进程,fork()函数返回0;
    3)如果创建出错,fork()函数返回-1。

    image.png
    fork时子进程复制父进程堆栈空间,之后父子进程独立。但是可读的代码段共享,具体的资源继承情况见表:
    image.png
    现在的linux内核在实现fork()函数时往往在创建子进程时并不立刻复制父进程的数据段和堆栈段,而是当子进程修改这些数据内容时复制操作才会发生,内核才会给子进程分配进程空间,将父进程的内容复制过来。这样更加合理,这样做的效率很高,是现代操作系统中重要的概念——"写时复制"。

    进程的结束——exit()函数
    退出函数exit()函数原型:
    #include <stdlib.h>
    void exit(int status);
    该函数的参数表示进程的退出状态,这个状态是一个整型,保存在全局变量$?中。
    $?是Linux Shell中的一个内置变量,其中保存的是最近一次运行的进程的返回值。这个返回值有以下3种情况:
    1)程序中的main函数运行结束,$?中保存main函数的返回值;
    2)程序运行中调用exit函数结束运行,$?保存exit函数的参数;
    3)程序异常退出,$?中保存异常出错的错误号。
    注意:使用$?时,shell找不到指定的进程,$?内置变量中的值是1,因此编写代码如果没有出错,则不要使用main函数的返回值为1,或者使用exit(1),以免引起混乱

    Linux中进程退出分为正常和异常退出两种:
    正常退出
    1)在main()函数中执行return;
    2)调用exit()函数;
    3)调用_exit()函数。
    异常退出
    1)调用abort函数;
    2)进程收到某个信号,而该信号使程序终止。
    退出方式的不同点
    exit和return
    1)return是函数执行完后的返回,return执行完后把控制权交给调用函数。
    2)exit是一个函数,带有参数,exit执行完把控制权交给系统;
    exit和abort的区别
    1)exit是正常终止进程;
    2)abort是异常终止;
    重点了解一下exit()和_exit()函数
    1)都是用来终止进程,执行exit或者_exit时,系统无条件停止剩下的操作,清除包括PCB在内的各种数据结构,终止进程运行;
    2)exit()在头文件stdlib.h中声明,而_exit()声明在头文件unistd.h中,exit中的参数exit_code为0时,代表进程正常终止,,否则说明执行过程有错。
    在调用_exit函数时,会关闭进程所有的文件描述符,清理内存以及其他一些内核清理函数,但不会刷新流(stdin、stdout、stderr...)区别的数据。exit函数是在_exit函数之上的一个封装,其会自动调用_exit,并在调用之前先刷新数据流数据。
    3)exit()和_exit函数最大的区别是前者在调用exit系统之前要检查文件的打开情况,把文件缓冲区的内容写回文件中。由于LInux的标准库函数中,有一种被称作“缓冲I/O”的操作,其特征就是对应每一个打开的文件,在内存中都有一片缓冲区。每次读文件时,会连续的读出若干条记录,这样在下次读文件时直接从缓冲区读取;同样每次写时也仅仅是写入内存的缓冲区,等满足了一定的条件后,再将缓冲区中的内容一次性写入文件。如果直接使用_exit()函数直接将进程关闭,缓冲区的数据就会丢失,因此为防止数据丢失,就一定要使用exit()函数。

    孤儿进程、僵尸进程、守护进程
    孤儿进程:指的是在其父进程执行完成或被终止 后仍继续运行的一类进程(比如父进程没有wait等待子进程结束,父进程直接结束,那么进程就会被init(pid=1)接收)

    僵尸进程:父进程通过fork创建子进程,子进程由于一些重大错误或者调用exit等导致子进程退出,而父进程没有用wait或者waitpid获取子进程状态,那么这些进程的进程号和一系列信息都会保存在系统中,时间长了,系统的进程号将会被耗尽,这种进程称为僵尸进程。

    Linux Daemon(守护进程):
    在Linux和UNIX操作系统中在系统的引导的时候会开启很多服务,这些服务就是守护进程。守护进程常常在系统引导装入时启动,在系统关闭时终止。
    是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。它不需要用户输入就能运行而且提供某种服务,不是对整个系统就是对某个用户程序提供服务。Linux系统的大多数服务器就是通过守护进程实现的。常见的守护进程包括系统日志进程syslogd、 web服务器httpd、邮件服务器sendmail和数据库服务器mysqld等。
    参考:https://www.jianshu.com/p/7390f73ad668

    创建一个简单的守护进程的步骤如下:

    image.png
    image.png

    进程间通信

    进程间通信至少可以使用传送打开文件的方式进行,但是效率低下。Linux支持常用的进程间通信方法:管道、消息队列、共享内存、信号量、套接字等。

    管道

    image.png
    image.png

    消息队列

    image.png
    image.png

    消息对列和有名管道有不少的相同之处都可以在没有关系的进程之间通信,与命名管道相比,消息队列的优势在于:
    1)消息队列可以独立于发送和接收进程而存在,从而消除了在同步命名管道的打开和关闭时可能产生的困难;
    2)可以同时通过发送消息以避免命名管道的同步和阻塞问题,而不需要由进程自己来提供同步方法;
    3)接收程序可以通过消息类型有选择的接收数据,而不是像命名管道那样,只能默认的接收。
    消息队列正在被淘汰。

    共享内存

    image.png
    image.png

    共享内存的优缺点:
    1)优点:方便,简单,直接访问内存,加快程序的效率;也不像无名管道那样要求通信的进程有一定的父子关系;
    2)缺点:共享内存没有提供同步机制,需要自己提供;

    信号量

    image.png
    image.png

    ipcs命令
    ipcs是一个Linux/UNIX命令,用于报告系统的消息队列、信号量、共享内存等。常用命令:
    1)ipcs -a用于列出本用户所有相关的ipcs参数;
    2)ipcs -q用于列出进程中的消息队列;
    3)ipcs -s用于列出所有的信号量;
    4)ipcs -m用于列出所有的共享内存信息;
    5)ipcs -l用于列出系统的限额;
    6)ipcs -t用于列出最后的访问时间;
    7)ipcs -u用于列出当前的使用情况;

    相关文章

      网友评论

          本文标题:明明白白——线程与进程

          本文链接:https://www.haomeiwen.com/subject/lehushtx.html