操作系统整理一:进程和线程

进程和线程

进程:CPU执行程序的过程

1、并发与并行

1、并发(Concurrent)

在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行。单核的 CPU 在某一个瞬间,只能运行一个进程。

  • 并发不是真正意义上的“同时进行”,只是CPU把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换。即操作系统轮流让各个任务交替执行,任务1执行0.01秒,切换到任务2,任务2执行0.01秒,再切换到任务3,执行0.01秒……这样反复执行下去。

  • 表面上看每个任务都是交替执行的,但是,由于CPU的执行速度很快,只要时间间隔处理得当,即可让用户感觉是所有的任务在同时执行。

  • 如:打游戏和听音乐两件事情在同一个时间段内都是在同一台电脑上完成了从开始到结束的动作。那么,就可以说听音乐和打游戏是并发的。

2、并行(Parallel):

  • 当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行。

  • 其实决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核也可以并行

  • 真正的并行执行多任务只能在多核CPU上实现,但是,由于任务数量远远多于CPU的核心数量,所以,操作系统也会自动把很多任务轮流调度到每个核心上执行。

3、超线程

  • 通过采用特殊的硬件指令,可以把两个逻辑内核模拟成两个物理超线程芯片,在单处理器中实现线程级的并行计算,同时在相应的软硬件的支持下大幅度提高运行效能,从而实现在单处理器上模拟双处理器的效能。其实,从实质上说,超线程是一种可以将CPU内部暂时闲置处理资源充分“调动”起来的技术。

  • 虽然采用超线程技术能够同时执行两个线程(如果每个进程同一时刻只能运行它的一个子线程的话,那就等同个能够同时执行两个进程),但它并不像两个真正的CPU那样,每个CPU都具有独立的资源。当两个线程都同时需要某一个资源时,其中一个要暂时停止,并让出资源,直到这些资源闲置后才能继续。因此超线程的性能并不等于两颗CPU的性能。

  • 超线程与多核的区别主要取决于资源的独立性。
    当运行的这两个线程属于同一进程,那么对于超线程技术,就会遇到两个线程的资源发生冲突的情况;对于多核,就不会发生这种情况,因此两个线程在两个不同的CPU之中运行,纵使它们属于同一进程。

并行和并发的区别:

  • 并发是在一段时间内宏观上多个程序同时运行,并行是在某一时刻,真正有多个程序在运行。即并发指的是多个事情在同一时间段内同时发生了。 并行指的是多个事情在同一时间点上同时发生了。

  • 并发的多个任务之间是互相抢占资源的。 并行的多个任务之间是不互相抢占资源的。

  • 只有在多CPU或者一个CPU多核的情况中,才会发生并行。否则,看似同时发生的事情,其实都是并发执行的。

4、协程

  • 协程又叫微线程,简单点说协程是进程和线程的升级版,进程和线程都面临着内核态和用户态的切换问题而耗费许多切换时间,而协程就是用户自己控制切换的时机,不再需要陷入系统的内核态.

  • 和普通函数不同的是,协程能知道自己上一次执行到了哪里,协程会在函数被暂停运行时保存函数的运行状态,并可以从保存的状态中恢复并继续运行,函数其实就是没有挂起点的协程而已。

2、进程

  • 编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着CPU会执行程序中的每一条指令,那么这个运行中的程序就被称为进程。

  • 进程指的是程序的运行过程,同一个程序执行两次,那也是两个进程。

  • 对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个Word就启动了一个Word进程。

  • 进程由程序、数据和进程控制块三部分组成

  • 进程控制块(PCB):
    是进程存在的唯一标识,如果进程消失了,PCB也随之消失。
    PCB的本质就是一个名为task_struct 的结构体。里边存放着进程几乎所有的信息。

2.1 进程的状态

  • 运行:该时刻进程占用CPU。
  • 就绪:可运行,但是因为其它进程正在运行而暂时停止。
  • 阻塞:该进程正在等待某一时间发生而暂时处于停止状态,即使CPU给她控制权也无法运行

进程:CPU执行程序的过程

在这里插入图片描述

2.2 进程具有的特征

动态性:进程是程序的一次执行过程,是临时的,有生命期的,是动态产生,动态消亡的;

并发性:任何进程都可以同其他进程一起并发执行

独立性:进程是系统进行资源分配和调度的一个独立单位;

异步性:由于进程间的相互制约,使进程具有执行的间断性,即进程按各自独立的、不可预知的速度向前推进。

2.3 进程的上下文切换

各个进程之间是共享CPU资源的,在不同的时候进程之间需要切换,让不同的进程可以在CPU上执行,那么这个进程切换到另一个进程运行即叫CPU上下文切换

包括:进程上下文切换、线程上下文切换、中断上下文切换。

进程是由内核态管理和调度的,所以进程的切换只能发生在内核态

2.4 特殊进程

僵尸进程、孤儿进程、守护进程

1、 僵尸进程

进程退出后,但是资源没有释放,处于僵尸状态的进程。

僵尸进程产生的原因

  • 子进程先于父进程退出,操作系统检测到进程的退出,通知父进程,但是父进程这时候正在执行其他操作,没有关注这个通知,这时候操作系统为了保护子进程,不会释放子进程资源,因为子进程的PCB中包含有退出原因。这时候因为既没有运行也没有退出,因此处于僵死状态,成为僵尸进程
  • 当进程退出并且父进程没有读取到子进程退出的返回代码时,就会产生僵尸进程。
    僵尸进程会进入终止状态,一直等待父进程读取退出的返回代码。
    所以只要进程退出,父进程还在运行,没有读取子进程的状态,子进程就进入僵尸状态。
  • 子进程变为僵尸状态,它就需要一直被维持下去,因为父进程必须知道子进程的事办的怎么样了,办完了,还是没有办完,还是异常退出,只要父进程没有读取,子进程就一直处于僵尸进程。
  • 一般处理就是关闭父进程,这样僵尸子进程也随之消失了。所以我们最好设置进程等待,等待子进程完成了工作,并且通知了父进程之后,在退出。

如何避免僵尸进程

  • 忽略SIGCHLD信号,这常用于并发服务器的性能的一个技巧。因为并发服务器常常fork很多子进程,子进程终结之后需要服务器进程去wait清理资源。如果将此信号的处理方式设为忽略,可让内核把僵尸子进程转交给init进程去处理,省去了大量僵尸进程占用系统资源。

2、孤儿进程

孤儿进程与僵尸进程在理解上可以认为相反。

父进程先于子进程退出,父进程退出后,子进程成为后台进程,并且父进程为1号进程。

3、守护进程

特殊的孤儿进程(脱离了与终端的关联+会话的关联)。在后台运行的,没有控制终端与之相连的进程。它独立于控制终端,周期性地执行某种任务。

一般的网络服务都是以守护进程的方式运行。

守护进程脱离终端的主要原因有两点:

  • 1)用来启动守护进程的终端在启动守护进程之后,需要执行其他任务。
  • 2)由终端上的一些键所产生的信号(如中断信号),不应对以前从该终端上启动的任何守护进程造成影响。(如其他用户登录该终端后,以前的守护进程的错误信息不应出现)

创建守护进程的过程:

  1. 调用fork创建子进程。
    父进程终止,让子进程在后台继续执行。
  2. 子进程调用setsid产生新会话期并失去控制终端,
    调用setsid()使子进程进程成为新会话组长和新的进程组长,同时失去控制终端。
  3. 忽略SIGHUP信号。
    会话组长进程终止会向其他进程发该信号,造成其他进程终止。
  4. 调用fork再创建子进程。
    子进程终止,子子进程继续执行,由于子子进程不再是会话组长,从而禁止进程重新打开控制终端。
  5. 改变当前工作目录为根目录。
    一般将工作目录改变到根目录,这样进程的启动目录也可以被卸掉。
  6. 关闭打开的文件描述符,打开一个空设备,并复制到标准输出和标准错误上。 避免调用的一些库函数依然向屏幕输出信息。
  7. 重设文件创建掩码清除从父进程那里继承来的文件创建掩码,设为0。
  8. 用openlog函数建立与syslogd的连接。

2.5 进程间的通信(IPC)

进程间通讯的7种方式

进程间通信(IPC):Inter Process Communication

每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。

进程间的通信方式有:管道、消息队列、共享内存、信号量、信号、socket

1)管道:是一种半双工的通信方式,数据只能单向流动,通过内核缓冲区实现数据传输。它可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中

  • 匿名管道(pipe):数据只能在具有亲缘关系的进程间流动。进程的亲缘关系通常是指父子进程或者兄弟进程关系
    进程只能访问自己或祖先创建的匿名管道,而不能访任意访问已经存在的管道——因为没有名字。
    通过pipe()系统调用来创建并打开,当最后一个使用它的进程关闭对他的引用时,pipe将自动撤销。

  • 命名管道(fifo):允许数据在无亲缘关系的进程间流动
    命名管道有名字,名字对应一个磁盘索引节点,有了这个文件名,任何进程有相应的权限都可以对它进行访问。但没有数据块。
    通过mknode()系统调用或者mkfifo()函数来建立的。
    一旦建立,任何有适当权限的进程都可以通过文件名将其打开和进行读写,而不局限于父子进程,当不再被进程使用时,FIFO在内存中释放,但磁盘节点仍然存在。

  • 管道的实质是一个内核缓冲区,进程以先进先出的方式从缓冲区存取数据:管道一端的进程顺序地将进程数据写入缓冲区,另一端的进程则顺序地读取数据,该缓冲区可以看做一个循环队列,读和写的位置都是自动增加的,一个数据只能被读一次,读出以后再缓冲区都不复存在了。当缓冲区读空或者写满时,有一定的规则控制相应的读进程或写进程是否进入等待队列,当空的缓冲区有新数据写入或慢的缓冲区有数据读出时,就唤醒等待队列中的进程继续读写。

2)消息队列:消息队列是保存在内核中的消息链表。用户进程可以向消息队列添加消息,也可以向消息队列读取消息。

  • 在发送数据时,消息队列会分成一个一个独立的数据单元,也就是消息体(数据块),如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。

  • 消息体是用户定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。

  • 消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点

  • 消息队列与管道通信相比,其优势是对每个消息指定特定的消息类型,接收的时候不需要按照队列次序,而是可以根据自定义条件接收特定类型的消息。

3)共享内存:允许两个或多个进程共享一个给定的存储区,这一段存储区可以被两个或两个以上的进程映射至自身的地址空间中,一个进程写入共享内存的信息,可以被其他使用这个共享内存的进程,通过一个简单的内存读取或者读出,从而实现了进程间的通信。

  • 具体就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要来回拷贝,大大提高了进程间通信的速度。

  • 优点是效率高。因为进程可以直接读写内存,而不需要任何数据的拷贝。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。

  • 一般而言,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时在重新建立共享内存区域;而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件,因此,采用共享内存的通信方式效率非常高。

  • 共享内存有两种实现方式:1、内存映射 2、共享内存机制

4)信号量:是一个整型的计数器,用来控制多个进程对资源的访问。 主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。若要在进程间传递数据需要结合共享内存。

  • 通常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。

  • 用了共享内存通信方式,会带来新的问题:如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制。

5)信号:用于通知接收进程某个事件已经发生。

  • 信号是进程间通信机制中唯一的异步通信机制,可以在任何时候发送信号给某一进程

6)Socket:套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同主机间的进程通信。

  • 管道、消息队列、共享内存、信号量和信号都是在一台主机上进行进程间通信

共享内存效率高的原因:

消息队列和管道基本上都需要四次数据拷贝:

  • 1,由用户空间的buf(缓冲区)中将数据拷贝到内核中。2,内核将数据拷贝到内存中。3,内存到内核。4,内核到用户空间的buf

  • 消息队列和管道都是内核对象,所执行的操作也都是系统调用,而这些数据最终是要存储在内存中执行的。因此不可避免的要经过4次数据的拷贝。

共享内存只有两次数据拷贝:

  • 1,用户空间到内存。 2,内存到用户空间。

  • 当执行mmap或者shmget时,会在内存中开辟空间,然后再将这块空间映射到用户进程的虚拟地址空间中,即返回值为一个指向一个内存地址的指针。当用户使用这个指针时,例如赋值操作,会引起一个从虚拟地址到物理地址的转化,会将数据直接写入对应的物理内存中,省去了拷贝到内核中的过程。当读取数据时,也是类似的过程,因此总共有两次数据拷贝。

在这里插入图片描述

2.6 进程调度算法

进程的调度

1、调度

进程都希望自己能够占用CPU进行工作,一旦操作系统把进程切换到运行状态,也就意味着该进程占用着CPU在执行,但是当操作系统把进程切换到其它状态时,那就不能在CPU中执行了,于是操作系统会选择下一个运行的进程。

选择一个进程运行这一功能是在操作系统中完成的,通常称为调度程序。

在进程的生命周期中,当进程从一个状态到另外一个状态变化的时候,会出发一次调度。

2、调度原则:

1.CPU利用率:

  • 调度程序应确保CPU是始终忙碌的状态,提高CPU利用率。

2.系统吞吐量:

  • 吞吐量表示的是单位时间内CPU完成进程的数量,长作业的进程会占用较长的CPU资源,因此会降低吞吐量,相反,较短作业的进程会提升系统的吞吐量。

3.周转时间:

  • 周转时间是进程运行和阻塞时间总和,一个进程的周转时间越小越好。

4.等待时间:

  • 进程处于就绪队列的时间,等待的时间越长,用户越不满意。

5.响应时间:

  • 用户提交请求到系统第一次产生响应花费的时间,在交互系统中,相应时间是衡量调度算法好坏的主要标准。
3、调度算法(单核CPU)

几个常用的操作系统进程调度算法

1.时间片轮转调度算法:

每个进程分配一个时间段,称为时间片,即该进程运行的时间。通常时间片设为 20ms~50ms 。

  • 如果时间到,进程还没有被执行完,CPU将被切走去执行其他的进程,该进程就会被移到就绪队列的末尾。

  • 如果未到时间,进程却阻塞了,CPU会立即被切走,

这样就可以保证就绪队列中的所有进程在一给定的时间内均能获得一时间片的处理机执行时间。换言之,系统能在给定的时间内响应所有用户的请求。

2.先来先服务(FCFS):

每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。

3.最短作业优先调度算法(SJF):

优先选择运行时间最短的进程来运行,这有助于提高系统吞吐量。

  • 在批处理系统中,短作业优先算法是一种比较好的算法,其主要的不足之处是长作业的运行得不到保证。

4.高响应比优先:

每次进行进程调度时,先计算响应比优先级,然后把响应比优先级最高的进程投入运行。

(优先权 = (等待时间 + 要求服务时间) / 要求服务时间)。

该算法既照顾了短作业,又考虑了作业到达的先后次序,不会使长作业长期得不到服务。因此,该算法实现了一种较好的折衷。当然,在利用该算法时,每要进行调度之前,都须先做响应比的计算,这会增加系统开销。

  • (1) 如果作业的等待时间相同,则要求服务的时间愈短,其优先权愈高,因而该算法有利于短作业。

  • (2) 当要求服务的时间相同时,作业的优先权决定于其等待时间,等待时间愈长,其优先权愈高,因而它实现的是先来先服务。

  • (3) 对于长作业,作业的优先级可以随等待时间的增加而提高,当其等待时间足够长时,其优先级便可升到很高,从而也可获得处理机。

5.最高优先级优先:

针对时间片,从就绪队列中选择最高优先级的进程进行运行。分为非抢占式和抢占式。

  • 非抢占式优先权算法:
    在这种方式下,系统一旦开始处理就绪队列中优先权最高的进程后,该进程便一直执行下去,直至完成;
    或因发生某事件使该进程放弃处理机时,系统方可再将处理机重新分配给另一优先权最高的进程。
    这种调度算法主要用于批处理系统中;也可用于某些对实时性要求不严的实时系统中。

  • 抢占式优先权调度算法
    在这种方式下,系统同样是把处理机分配给优先权最高的进程,使之执行。但在其执行期间,只要又出现了另一个其优先权更高的进程,进程调度程序就立即停止当前进程(原优先权最高的进程)的执行,重新将处理机分配给新到的优先权最高的进程。
    因此,在采用这种调度算法时,是每当系统中出现一个新的就绪进程i 时,就将其优先权Pi与正在执行的进程j 的优先权Pj进行比较。如果Pi≤Pj,原进程Pj便继续执行;但如果是Pi>Pj,则立即停止Pj的执行,做进程切换,使i 进程投入执行。
    这种抢占式的优先权调度算法能更好地满足紧迫作业的要求,故而常用于要求比较严格的实时系统中,以及对性能要求较高的批处理和分时系统中。

6.多级反馈队列调度算法:

先来先服务 + 时间片轮转调度算法 + 抢占式最高优先级优先调度

「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。

「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列。

实施过程:

  • (1) 应设置多个就绪队列,并为各个队列赋予不同的优先级。第一个队列的优先级最高,第二个队列次之,其余各队列的优先权逐个降低。该算法赋予各个队列中进程执行时间片的大小也各不相同,在优先权愈高的队列中,为每个进程所规定的执行时间片就愈小。例如,第二个队列的时间片要比第一个队列的时间片长一倍,……,第 i+1个队列的时间片要比第 i 个队列的时间片长一倍。

  • (2)先来先服务 +时间片轮转 :
    当一个新进程进入内存后,首先将它放入第一队列的末尾,按FCFS(先来先服务)原则排队等待调度。
    当轮到该进程执行时,如它能在该时间片内完成,便可准备撤离系统;如果它在一个时间片结束时尚未完成,调度程序便将该进程转入第二队列的末尾,再同样地按FCFS原则等待调度执行;如果它在第二队列中运行一个时间片后仍未完成,再依次将它放入第三队列,……,如此下去,当一个长作业(进程)从第一队列依次降到第n队列后,在第 n 队列便采取按时间片轮转的方式运行。

  • (3) 抢占式最高优先级优先调度:
    仅当第一队列空闲时,调度程序才调度第二队列中的进程运行;仅当第1~(i-1)队列均空时,才会调度第 i 队列中的进程运行。如果处理机正在第 i 队列中为某进程服务时,又有新进程进入优先权较高的队列(第1~(i-1)中的任何一个队列),则此时新进程将抢占正在运行进程的处理机,即由调度程序把正在运行的进程放回到第 i 队列的末尾,把处理机分配给新到的高优先权进程。

2.7 多进程模型

基于最原始的阻塞网络 I/O, 如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理请求。

服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork() 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。这两个进程刚复制完的时候,几乎一摸一样。不过,会根据返回值来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。

正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接 Socket 」和客户端通信了,可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。

3、线程

  • 有些进程不止同时干一件事,比如Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程(Thread)。

  • 线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位

  • 一个标准的线程由线程ID,当前指令指针PC,寄存器和堆栈组成。而进程由内存空间(代码,数据,进程空间,打开的文件)和一个或多个线程组成。

  • 一个进程可以有一个或多个线程,同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程都有独立一套的寄存器和栈,这样可以确保线程的控制流是相对独立的。

  • 一个进程里的多个线程可以同时执行,多线程的执行方式和多进程是一样的,也是由操作系统在多个线程之间快速切换,让每个线程都短暂地交替运行,看起来就像同时执行一样。当然,真正地同时执行多线程需要多核CPU才可能实现

  • 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃。

  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;

一个进程中可以同时存在多个线程、各个线程之间可以并发执行、各个线程之间可以共享地址空间和文件等资源。

3.1 为什么有了进程还要有线程

进程可以使多个程序并发执行,以提高资源的利用率和系统的的吞吐量,但是也有缺点:进程在同一时间只能干一件事情、进程在执行的过程中被阻塞就会被挂起,即使有些工作不依赖与等待的资源,也会被挂起。

基于以上的缺点,操作系统引入了比进程粒度更小的线程,作为并发执行的基本单位,从而减少程序在并发执行时所付出的时间和空间开销,以提高并发性能。

3.2 线程的上下文切换

看线程是否属于同一个进程:

1.当两个线程不是属于同一个进程时,则切换过程和进程上下文切换一样。

2.当两个线程是属于同一个进程时,只需要切换线程的私有数据、寄存器等不共享的数据。

故线程的上下文切换相比进程开销小的多。

3.3 线程同步

并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步

线程之线程同步

3.3.1 同步、异步、轮询的概念

同步,

  • 简单地说就是调用一个过程时,假如过程还在执行状态,没有返回结果,那么在该过程返回之前,就不能继续做下一件事情。

  • 同步就好比操作A应在操作B之前执行,操作C必须在操作A和操作B都完成之后才能执行等。

  • B一直等着A,等A完成之后,B再执行任务。

异步

  • 异步是相对于同步而言的,意思与同步相反。即调用一个过程时,就接着做下面的事情,不立即获得该过程的返回值。

  • B不需要一直等着A, B先做其他事情,等A完成后A通知B。

轮询:

  • B没有一直等待A,B过一会来问一下A,过一会问下A

3.3.2 为什么需要线程同步

当多个控制线程共享相同的内存时,需要确保每个线程看到一致的数据视图。如果每个线程使用的变量都是其他线程不会读取或修改的,那么就不会存在一致性问题。同样地,如果变量是只读的,多个线程同时读取该变量也不会有一致性问题。但是,当某个线程可以修改变量,而其他线程也可以读取或修改这个变量的时候,就需要对这些线程进行同步,以确保它们在访问变量的存储内容时不会访问到无效的数值。

需要同步的情况举例:

1)两个线程读写相同变量:

  • 线程A读取变量然后给这个变量赋予一个新的值,但写操作需要两个存储器周期。当线程B在这两个存储器写周期中间读取这个相同的变量时,它就会得到不一致的值。
  • 为了解决这个问题,线程不得不使用锁,在同一时间只允许一个线程访问该变量。图11-3描述了这种同步。如果线程B希望读取变量,它首先要获取锁;同样地,当线程A更新变量时,也需要获取这把同样的锁。因而线程B在线程A释放锁以前不能读取变量。
    在这里插入图片描述

2)当两个或多个线程试图在同一时间修改同一变量时,也需要进行同步。

  • 考虑变量递增操作的情况,增量操作通常可分为三步:(1)从内存单元读入寄存器。
    (2)在寄存器中进行变量值的增加。
    (3)把新的值写回内存单元。
  • 如果两个线程试图在几乎同一时间对同一变量做增量操作而不进行同步的话,结果就可能出现不一致。
  • 变量可能比原来增加了1,也有可能比原来增加了2,具体是1还是2取决于第二个线程开始操作时获取的数值。
    如果第二个线程执行第一步要比第一个线程执行第三步早,第二个线程读到的初始值就与第一个线程一样,它为变量加1,然后再写回去,事实上没有实际的效果,总的来说变量只增加了1。
    在这里插入图片描述

3.3.3 线程同步的方式

如果修改操作是原子操作,那么就不存在竞争。在前面的例子中,如果增加1只需要一个存储器周期,那么就没有竞争存在。

如果数据总是以顺序一致的方式出现,就不需要额外的同步。当多个线程并不能观察到数据的不一致时,那么操作就是顺序一致的。

在现代计算机系统中,存储访问需要多个总线周期,多处理器的总线周期通常在多个处理器上是交叉的,所以无法保证数据是顺序一致的。

常见的方法有:互斥量,自旋锁、信号量、条件变量、读写锁

1、互斥量(mutex)

从本质上说是一把锁,在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量上的锁。

  1. 对互斥量进行加锁以后,任何其他试图再次对互斥量加锁的线程将会被阻塞直到当前线程释放该互斥锁。

  2. 如果释放互斥锁时有多个线程阻塞,所有在该互斥锁上的阻塞线程都会变成可运行状态,第一个变为运行状态的线程可以对互斥量加锁,其他线程将会看到互斥锁依然被锁住,只能回去再次等待它重新变为可用。

  3. 在这种方式下,每次只有一个线程可以向前执行。互斥就好比操作A和操作B不能在同一时刻执行。

  4. 在设计时需要规定所有的线程必须遵守相同的数据访问规则,只有这样,互斥机制才能正常工作。

  5. 在使用之前必须初始化,在释放它们底层的内存前必须销毁。

  6. 避免死锁

产生死锁的情况:

  • 1)如果线程试图对同一个互斥量加锁两次,那么它自身就会陷入死锁状态

  • 2)程序中使用多个互斥量时,如果允许一个线程一直占有第一个互斥量,并且在试图锁住第二个互斥量时处于阻塞状态,但是拥有第二个互斥量的线程也在试图锁住第一个互斥量,这时就会发生死锁。因为两个线程都在相互请求另一个线程拥有的资源,所以这两个线程都无法向前运行,于是就产生死锁。

解决方法:

1)可以通过小心地控制互斥量加锁的顺序来避免死锁的发生。

  • 例如,假设需要对两个互斥量A和B同时加锁,如果所有线程总是在对互斥量B加锁之前锁住互斥量A,那么使用这两个互斥量不会产生死锁(当然在其他资源上仍可能出现死锁);
  • 类似地,如果所有的线程总是在锁住互斥量A之前锁住互斥量B,那么也不会发生死锁。
  • 只有在一个线程试图以与另一个线程相反的顺序锁住互斥量时,才可能出现死锁。

2)可以先释放占有的锁,然后过一段时间再试。

  • 有时候应用程序的结果使得对互斥量加锁进行排序是很困难的,如果涉及了太多的锁和数据结构,可用的函数并不能把它转换成简单的层次,那么就需要采用另外的方法。这种情况可以使用pthread_mutex_trylock接口避免死锁。如果已经占有某些锁而且pthread_mutex_trylock接口返回成功,那么就可以前进;但是,如果不能获取锁,可以先释放已经占有的锁,做好清理工作,然后过一段时间重新尝试。
2、自旋锁(Spin Locks)

顾名思义就是一个死循环,不停的轮询,当一个线程未获得自旋锁时,不停的轮询获取锁。

如果自旋锁很快被释放,那么性能就会很高,

如果自旋锁长时间不能够被释放,甚至里面还有大量的IO阻塞,就会导致其它获取锁的线程一直空轮询,导致CPU占用率较高。

  • 自旋锁适于用这样的情况:锁被其他线程短期持有(很快会被释放),而且等待该锁的线程不希望在阻塞期间被取消调度,因为这会带来一些开销。

自旋锁与互斥量的区别:

  • 若线程不能获取锁,互斥量通过休眠的方式(线程被暂时取消调度,切换至其他可运行的线程)来阻塞线程;
  • 若线程不能获取锁,自旋锁通过忙等(busy-waiting,spinning)的方式(线程不会被取消调度,一直在处于运行状态)来阻塞线程。
3、条件变量

条件变量可以让调用线程在满足特定的条件的情况下运行,不满足条件时阻塞等待被唤醒,必须与互斥锁搭配使用。

  • 条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。

  • 条件本身是由互斥量保护的。线程在改变条件状态前必须首先锁住互斥量,其他线程在获得互斥量之前不会察觉到这种改变,因为必须锁定互斥量以后才能计算条件。

  • 条件的检测是在互斥锁的保护下进行的。
    如果一个条件为假,一个线程自动阻塞,并释放等待状态改变的互斥锁。
    如果另一个线程改变了条件,它发信号给关联的条件变量,唤醒一个或多个等待它的线程,重新获得互斥锁,重新评价条件。
    如果两进程共享可读写的内存,条件变量可以被用来实现这两进程间的线程同步。

4、读写锁

是一种特殊的自旋锁,允许多个读者同时访问以提高读性能,但对于写操作是互斥的。读者只会读取数据,不会修改数据,而写者既可以读也可以写。

  • 读写锁也叫做共享-独占锁,当读写锁以读模式锁住时,它是以共享模式锁住的;当它以写模式锁住时,它是以独占模式锁住的。

  • 读写锁与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态要么是不加锁状态,而且一次只有一个线程可以对其加锁。

  • 读写锁在使用之前必须初始化,在释放它们底层的内存前必须销毁。

  • 一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。

  • 读写锁非常适合于对数据结构读的次数远大于写的情况。

读写锁的状态:

  • 读模式下加锁状态:
    当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,
    此时如果有别的线程希望以写模式对此锁进行加锁,读写锁通常会阻塞随后的读模式锁请求直到所有的线程释放读锁。
    这样可以避免读模式锁长期占用,而等待的写模式锁请求一直得不到满足。

  • 写模式下加锁状态:
    当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。

  • 不加锁状态。

5、Barriers(屏障、关卡)

关卡是一种同步机制,它可以协调并行工作的多个线程。关卡使每一个线程等待直到所有合作的线程都到达了同一点,然后再从这一点开始继续执行。

关卡它允许任意数量的线程等待,直到所有的线程完成处理,但这些线程不一定退出。当所有的线程都到达这个关卡的时候它们可以继续执行工作。

6、信号量

信号量用在多线程多任务同步的,一个线程完成了某一个动作就通过信号量告诉别的线程,别的线程再进行某些动作。

互斥锁是用在多线程多任务互斥的,一个线程占用了某一个资源,那么别的线程就无法访问,直到这个线程unlock,其他的线程才开始可以利用这个资源。

也就是说,信号量不一定是锁定某一个资源,而是流程上的概念,信号量是操作系统提供的一种协调共享资源访问的方法。

3.4 线程之间共享哪些资源

1、进程地址空间

进程地址空间(process address space),就是从进程的视角看到的地址空间,是进程运行时所用到的虚拟地址的集合

Linux的进程地址空间[一]

在这里插入图片描述

2、32位操作系统和64位操作系统的区别

32位和64位操作系统是指:CPU一次处理数据的能力是32位还是64位,这里涉及到的是处理器运算位数。简单的说32位系统的地址总线是32位的,而64位系统的地址总线是64位的。

  • 内存寻址方面:32位的操作系统,最多支持 2^32 = 4GB的内存,实际内存为3.25G;
    64位系统支持4G— 256G内存,

  • 32位的系统不能完全支持64位的处理器,
    64位的操作系统支持基于32位、64位的处理器,

  • 32位的操作系统,支持基于32位的软件,不能运行64位的软件;
    而64位的系统一般这两种类型的都支持,基本上与各种软件都兼容,

  • 32和64表示CPU可以处理最大位数,一次性的运算量不一样,理论上64位处理数据的速度会比32位快1倍。
    64位指令集可以运行64位数据指令,也就是说处理器一次可提取64位数据(只要两个指令,一次提取8个字节的数据),比32位(需要四个指令,一次提取4个字节的数据)提高了一倍,一个字节等于8位。

  • 64位系统都比32位系统大的多,比如win7 64位比win7 32位系统大700M左右。

3、 线程共享的资源

操作系统---------------线程之间共享的资源有哪些

由于线程运行的本质就是函数的执行,函数运行时信息是保存在栈帧中的,因此每个线程都有自己独立的、私有的栈区。同时函数运行时需要额外的寄存器来保存一些信息,像部分局部变量之类,这些寄存器也是线程私有的,一个线程不可能访问到另一个线程的这类寄存器信息。故线程的栈区、程序计数器、栈指针以及函数运行使用的寄存器是线程私有的。

线程共享进程地址空间中除线程上下文信息中的所有内容,即线程可以直接读取这些内容。所以线程共享资源如下:栈区、堆区、代码区、数据区、动态链接库、打开的文件

  • 1.代码区:
    这里保存的是编写的代码,即编译后的可执行及其指令。

  • 2.数据区:
    进程地址空间的数据区,这里存放的就是所谓的全局变量、静态变量。
    全局变量是与具体某一函数无关的,所以也与特定线程无关,因此也是共享的
    静态变量:虽然对于局部变量来说,它在代码中是“放”在某一函数中的,但是其存放位置和全局变量一样,存于堆中开辟的.bss和.data段,因此是共享的。(全局区静态区是一个地方)

  • 3.堆区:
    new出来的数据就放在这个区域,只要知道变量的地址,也就是指针,任何一个线程都可以访问指针指向的数据。

  • 4.栈区:
    如果一个线程能拿到来自另一个线程栈帧上的指针,那么该线程就可以改变另一个线程的栈区,也就是说这些线程可以任意修改本属于另一个线程栈区中的变量。
    尽管栈区是线程的私有数据,但由于栈区没有添加任何保护机制,一个线程的栈区对其它线程是可以见的,也就是说我们可以修改属于任何一个线程的栈区。

  • 5.动态链接库:
    静态链接的意思是说把所有的机器指令一股脑全部打包到可执行程序中,
    动态链接的意思是我们不把动态链接的部分打包到可执行程序,而是在可执行程序运行起来后去内存中找动态链接的那部分代码

  • 6.文件:
    如果程序在运行过程中打开了一些文件,那么进程地址空间中还保存有打开的文件信息,进程打开的文件也可以被所有的线程使用,这也是属于线程间的共享资源。使用这些公共资源的线程必须同步。

线程共享的环境包括:

  • 进程代码段、进程的公有数据(利用这些共享的数据,线程很容易的实现相互之间的通讯)、进程打开的文件描述符、信号的处理器、进程的当前目录和进程用户ID与进程组ID。

4、堆区和栈区的区别

堆和栈的区别 之 数据结构和内存

栈区(stack)— 由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。

堆区(heap)— 一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。它与数据结构中的堆是两回事,分配方式类似于链表。

区别:

1)申请方式:

  • 栈:由系统自动分配。例如,声明在函数中一个局部变量int b;系统自动在栈中为b开辟空间

  • 堆:需要程序员自己申请,并指明大小,在c中用malloc函数,在C++中用new运算符。

2)申请后系统的响应:

  • 栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。

  • 堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确的释放本内存空间。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。

3)申请大小的限制:

  • 栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。

  • 堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。

4)申请效率的比较:

  • 栈:由系统自动分配,速度较快。但程序员是无法控制的。

  • 堆是由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。

  • 另外,在WINDOWS下,最好的方式是用VirtualAlloc分配内存,他不是在堆,也不是在栈,是直接在进程的地址空间中保留一块内存,虽然用起来最不方便。但是速度快,也最灵活。

5)堆和栈中的存储内容:

  • 栈:在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。

  • 堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容由程序员安排。

6)缓存方式区别:

  • 栈使用的是一级缓存,他们通常都是被调用时处于存储空间中,调用完毕立即释放;

  • 堆是存放在二级缓存中,生命周期由虚拟机的垃圾回收算法来决定(并不是一旦成为孤儿对象就能被回收)。所以调用这些对象的速度要相对来得低一些。

7)存取效率的比较:

  • 访问堆的一个具体单元,需要两次访问内存,第一次得取得指针,第二次才是真正得数据,而栈只需访问一次。

  • 另外,堆的内容被操作系统交换到外存的概率比栈大,栈一般是不会被交换出去的。

  • 因此从操作系统层面来看,栈的效率比堆高。

从堆上和栈上建立对象哪个快

从两方面来考虑:

  • 分配和释放:堆在分配和释放时都要调用函数malloc、free,比如分配时会到堆空间去寻找足够大小的空间(因为多次分配释放后会造成内存碎片),这些都会花费一定的时间。而栈却不需要这些。

  • 访问时间:访问堆的一个具体单元,需要两次访问内存,第一次取得指针,第二次才是真正的数据,而栈只需要访问一次。另外,堆的内容被操作系统交换到外存的概率比较大,栈一般是不会被交换出去的。

3.5 线程的优点

  • 充分利用CPU资源;

  • 实现了进程内并发,使用任务的粒度分得更细,有利于开发人员对任务的分解、抽象(分解与抽象原则);

  • 实现了进程内异步事件的处理,尤其是GUI事件、服务端应用等;

  • 了程序的运行效率。

3.6 多线程模型

线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源的,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时是不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。

当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。

如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。

那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出已连接 Socket 进程处理。

需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。

上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的。

4、进程和线程的区别

  1. **线程是程序执行的最小单位,是cpu调度的单位。进程是操作系统分配资源的最小单位;**进程要操作CPU,必须要先创建一个线程。

  2. 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线
    进程中第一个线程是主线程,主线程可以创建其他线程;其他线程也可以创建线程;线程之间是平等的。

  3. 进程之间相互独立,每个进程拥有一个完整的资源平台。
    而线程只独享必不可少的资源,如寄存器和栈:同一进程下的各个线程之间共享程序的内存空间(包括代码段,数据集,堆等)及一些进程级的资源(如打开文件和信号等)。
    某进程内的线程在其他进程不可见;

  4. 调度和切换:
    线程上下文切换比进程上下文切换要快得多,线程能减少并发执行的时间和空间开销
    同一个进程里的线程之间可以直接访问。
    两个进程想通信必须通过一个中间代理来实现。

对于线程相比进程能减少开销,体现在:

1.线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;

2.线程的终止时间比进程快,因为线程释放的资源相比进程少很多;

3.同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;

4.由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;

由于进程的独立性,当进程间要相互通信时,系统只能提供各种外部方法,比较繁琐,而线程间的通信可以通过共享数据来实现;

所以,线程比进程不管是时间效率,还是空间效率都要高。

在这里插入图片描述

5、 用户态和内核态

进程是由内核态管理和调度的,所以进程的切换只能发生在内核态。

1、内核态和用户态的概念

内核态:

  • 当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。

  • 此时处理器处于特权级最高的(0级)内核代码中执行。

  • 当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。

  • 每个进程都有自己的内核栈。

用户态:

  • 当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。

  • 此时处理器在特权级最低的(3级)用户代码中运行。

  • 当正在执行用户程序而突然被中断程序中断时,此时用户程序也可以象征性地称为处于进程的内核态。因为中断处理程序将使用当前进程的内核栈。这与处于内核态的进程的状态有些类似。

2、为什么要有内核态和用户态

由于需要限制不同的程序之间的访问能力, 防止他们获取别的程序的内存数据,或者获取外围设备的数据, 并发送到网络,CPU划分出两个权限等级 – 用户态和内核态。

3、内核态和用户态的区别

用户态和内核态是操作系统的两种运行状态。

用户态和内核态的区别

内核态:

  • 处于内核态的 CPU 可以访问任意的数据,包括外围设备,比如网卡、硬盘等,

  • 处于内核态的 CPU 可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况。

  • 一般处于特权级 0 的状态我们称之为内核态。

用户态:

  • 处于用户态的 CPU 只能受限的访问内存,并且不允许访问外围设备,

  • 用户态下的 CPU 不允许独占,也就是说 CPU 能够被其他程序获取。

4、内核态和用户态的切换

用户态切换到内核态的3种方式:

1)系统调用

  • 这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作,而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。

2)异常

  • 异常是由CPU执行指令的内部事件引起的,如非法操作码、地址越界、算术溢出等。

  • 当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。

3)外围设备的中断

  • 外部中断是指由CPU执行指令以外的事件引起的,如IO完成中断,表示设备输入/输出处理已经完成,处理器能够发送下一个输入/输出请求。此外还有时钟中断、控制台中断等。

  • 当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

5、系统调用机制的工作流程

在CPU中的实现称之为陷阱指令(Trap Instruction):

  • 用户态程序将一些数据值放在寄存器中, 或者使用参数创建一个堆栈(stack frame), 以此表明需要操作系统提供的服务。

  • 用户态程序执行陷阱指令

  • CPU切换到内核态,并跳到位于内存指定位置的指令,这些指令会读取程序放入内存的数据参数,并执行程序请求的服务。
    这些指令称之为陷阱(trap)或者系统调用处理器(system call handler),是操作系统的一部分, 他们具有内存保护,不可被用户态程序访问

  • 系统调用完成后,操作系统会重置CPU为用户态并返回系统调用的结果。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值