进程和线程
概念
进程:是并发执行的程序在执行过程中分配和管理资源的基本单位,是一个动态概念,竞争计算机系统资源的基本单位。
线程:是进程的一个执行单元,是进程内可调度实体。比进程更小的独立运行的基本单位。线程也被称为轻量级进程。
区别
- 进程是资源分配的最小单位。 线程是CPU调度的基本单位。
- 进程拥有独立的内存单元。同一进程下的多个线程共享内存。线程只独享指令流执行的必要资源,如寄存器和栈。
- 线程共享本进程的资源如内存、I/O、cpu等,不利于资源的管理和保护,而进程之间的资源是独立的,能很好的进行资源管理和保护。
- 进程间不会相互影响,多考虑通信。线程间会相互影响,多考虑同步,通信可不通过内核直接通信。
- 程编程调试简单可靠性高,但是创建销毁切换开销大。线程正相反,开销小,切换速度快,但是编程调试相对复杂。
- 多进程要比多线程健壮,一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。
-
每个独立的进程有一个程序运行的入口、顺序执行序列和程序入口,执行开销大。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,执行开销小。
线程独占和共享的资源
共享资源
- 进程申请的堆内存
- 进程打开的文件描述符
- 进程的全局数据(可用于线程之间通信)
- 进程ID、进程组ID
- 进程目录
- 信号处理器
独占资源
- 线程ID,同一进程中每个线程拥有唯一的线程ID。
- 寄存器组的值,由于线程间是并发运行的,每个线程有自己不同的运行线索,当从一个线 程切换到另一个线程上 时,必须将原有的线程的寄存器集合的状态保存,以便将来该线程在被重新切换到时能得以恢复。
- 线程堆栈,线程可以进行函数调用,必然会使用大函数堆栈。
- 错误返回码,线程执行出错时,必须明确是哪个线程出现何种错误,因此不同的线程应该拥有自己的错误返回码变量。
- 信号屏蔽码,由于每个线程所感兴趣的信号不同,所以线程的信号屏蔽码应该由线程自己管理。但所有的线程都共享同样的信号处理器。
- 线程的优先级,由于线程需要像进程那样能够被调度,那么就必须要有可供调度使用的参数,这个参数就是线程的优先级。
进程间通信
由于进程间相互独立不共享内存空间和资源,不能直接使用全局变量直接通信,但是进程是共享内核空间的,可通过内核空间进行进程间通信,如下图:
进程间通信的方法有:
管道
先看实现:
$ mkfifo mypipe //创建一个管道,命名为mypipe
$ echo "hello world!" > mypipe //发送信息到管道内
/* 接下来键入的命令都没有响应了,我认为是发送信息到管道但是没有取用,这个进程就阻塞在这里了 */
$ cat < mypipe //从先前创建的管道中取信息
hello world! //打印先前输入的信息
$ ls -l mypipe //查看创建的管道信息,实质上管道是个文件
/* 以上部分需要重开一个进程进行,执行cat操作后原先阻塞的进程也成功运行了,可以看图 */
实际上管道操作就很简单,创建管道然后输入内容就好 ,但是使用起来是比较麻烦的
同时我使用的是命名管道,可以形成一个管道文件,因此可以在任意两个进程之间进行通信连接,但匿名管道是没有指定的文件的,网上查阅了解到匿名管道只能在父与子进程或者同一个父进程的子进程之间通信(这一点比较好理解,毕竟匿名管道没有文件实体,那么管道的文件内容只能靠fork的操作传递给子进程).
命名管道应该是双向交替通信的,即双方都能发送消息接收消息,只是不能同时接发消息,并且管道内的信息在未完全取出时就是单向的,这个可以尝试,当两个进程同时像一个管道发送消息时,这个管道就卡死了.
至于匿名管道可以参考这篇文章进程通信详解
这里我觉得不会常用就不加赘述了,这位大佬描述得很详细了.
消息队列
消息队列就是管道的改善版,前面提到管道在实际通信时是单向的,仅允许一方发送一方接受,但是消息队列是允许双方发送和接受的——就像有一个独立的队列存在,任意进程都可以向其发送消息,接收方可以指定队列的序号,接收信息的大小等来进行自由地接收消息;我认为最重要的是他不会像命名管道那样极容易发生阻塞,同步问题
我使用过RabbitMQ,使用起来是很简单的,只需要连接上消息队列就好
当然消息队列也有一定的缺陷,比如发送信息的大小限制,因为内核中的消息队列大小其实是确定分配好的,在创建消息队列时便需要指定.
目前其实是云计算方面用消息队列比较多,将消息队列部署在云端服务器上可以方便多个计算机进行实时的通信
关于RabbitMQ的详细使用方法见这篇文章RabbitMQ详解
或者直接上官方文档看,比较容易上手.
共享内存
和消息队列有点相似的是共享内存也是独立于进程的,其实也就是独立的一块内存空间供各个进程取用,发送消息,获取变量等等——其背后的原理是现代计算机的进程都采用虚拟内存的技术,通过映射到不同物理内存实现进程的相互独立,但是共享内存这一段虚拟内存是公用的,映射到同一块物理内存.
相较于消息队列,共享内存的通信使用更为方便,信息取用更自由
原理图如下:
还有一个重要的点在于共享内存用的就是实际的物理地址,直接取用即可,无需像消息队列那样将变量信息从内核态拷贝到用户态或者反过来,避免了消息的拷贝开销(因为用的是虚拟内存映射)
信号量
共享内存存在的一个问题是这块内存各个进程都能使用,假设多个进程对同一块内存同时进行读写,就会发生冲突(毕竟共享内存的机制下,公用的那块内存对每个进程来说都相当于是自己的一块内存)
由于以上问题的存在就有了信号量这个机制,作为<<操作系统>>的重要内容,信号量要解决的其实不是进程的通信问题,而是进程间的互斥同步问题
信号量表示资源的数量,控制信号量的方式有两种原子操作:
- 一个是 P 操作,这个操作会把信号量减去 -1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
- 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;
P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。
原理如下图:
具体的过程如下:
- 进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。
- 若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。
- 直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。
这是互斥信号量,是为了阻止多个进程在同一资源上(共享内存)的冲突
也有多进程需要协同操作共同进行的情况,这时候就需要同步信号量
例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。
那么这时候,就可以用信号量来实现多进程同步的方式,我们可以初始化信号量为 0
。
具体过程:
- 如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;
- 接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;
- 最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。
可以发现,信号初始化为 0
,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。
信号
处理异常进程时的工具,实质上时shell相关的操作,包括kill和键入Ctrl+c,用户进程接收到相关的信号命令后会有以下不同的反应:
1.执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。Core 的意思是 Core Dump,也即终止进程后,通过 Core Dump 将当前进程的运行状态保存在文件里面,方便程序员事后进行分析问题在哪里。
2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL
和 SEGSTOP
,它们用于在任何时候中断或结束某一进程。
可以在shell内通过如下操作查看各种信号
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
Socket
通过套接字实现不同主机上的进程通信,分为TCP和UDP两种,和计网内容重合,之后细说
TCP流程
- 服务端和客户端初始化
socket
,得到文件描述符; - 服务端调用
bind
,将绑定在 IP 地址和端口; - 服务端调用
listen
,进行监听; - 服务端调用
accept
,等待客户端连接; - 客户端调用
connect
,向服务器端的地址和端口发起连接请求; - 服务端
accept
返回用于传输的socket
的文件描述符; - 客户端调用
write
写入数据;服务端调用read
读取数据; - 客户端断开连接时,会调用
close
,那么服务端read
读取数据的时候,就会读取到了EOF
,待处理完数据后,服务端调用close
,表示连接关闭。
这里需要注意的是,服务端调用 accept
时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。
所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。
成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。
UDP流程
UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind。
对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。
另外,每次通信时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。
Unix Domain Socket
Unix域套接字只能用于在同一个计算机的进程间进行通信。虽然网络套接字也可以用于单机进程间的通信,但是使用Unix域套接字效率会更高,因为Unix域套接字仅仅进行数据复制,不会执行在网络协议栈中需要处理的添加、删除报文头、计算校验和、计算报文顺序等复杂操作,因而在单机的进程间通信中,更加推荐使用Unix域套接字。
线程间通信
同个进程下的线程之间都是共享进程的资源,只要是共享变量都可以做到线程间通信,比如全局变量,所以对于线程间关注的不是通信方式,而是关注多线程竞争共享资源的问题,信号量也同样可以在线程间实现互斥与同步:
- 互斥的方式,可保证任意时刻只有一个线程访问共享资源;
- 同步的方式,可保证线程 A 应在线程 B 之前执行;
线程同步
互斥锁
条件变量
POSIX信号量