北航OS课程笔记--四、进程管理
进程管理
进程与线程
进程概念的引入
基本概念
-
- 顺序执行
- 并行:两个程序在同一时间度量下同时运行在不同的处理机上
- 并发:两个活动在某一指定时刻,无论是在同一处理机还是不同处理机上,二者都处在各自的起点到终点之间的某一处。
-
程序的顺序执行与特征
- 顺序性:按程序结构指定的次序执行
- 封闭性:独占全部资源,计算机状态只由该程序的控制逻辑所决定
- 可再现性:初始条件相同则结果相同
-
程序的并发执行在时间上是重叠的:一个程序的第一条指令是在另一个程序的最后一条指令完成之前开始的
程序并发执行时的特征:
-
间断性:执行——暂停——执行
-
非封闭性:多个程序共享系统中的资源
-
不可再现性,因为发生了竞争:多个进程在读写同一个共享数据时结果依赖于它们执行的相对时间
竞争条件:多个进程并发访问和操作同一数据,且执行结果与访问的顺序有关
-
Bernstein 条件
- 定义:R(Si):Si的读子集,其值在Si中被引用的变量的集合
- W(Si):Si的写子集,其值在Si中被改变的变量的集合
两个进程S1和S2可并发,当且仅当以下条件同时成立:

没有读写冲突和写写冲突。

Bernstein条件是判断程序并发执行结果是否可再现的充分条件。
进程的定义
- 进程是程序的一次执行;
- 进程是可以和别的计算并发执行的计算。
- 进程可定义为一个数据结构,及能在其上进行操作的一个程序。
- 进程是一个程序及其数据在处理机上顺序执行时所发生的活动。
- 进程是程序在一个数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位。
进程的特征
- 动态性:进程是程序的一次执行过程,,因创建而产 生,因调度而执行,因无资源而暂停,因撤消而消亡;而程序是静态实体。
- 并发性
- 独立性:进程是独立运行的基本单位
- 异步性:也叫制约性,进程之间相互制约,进程以各自独立的不可预知的速度向前推进。
- 结构特征:程序、数据、进程控制块PCB
作业、进程与程序
- 进程是动态的,程序是静态的。
- 进程是暂时的,程序是永久的。
- 作业通常包括程序、数据和操作说明书。
- 进程与程序的组成不同:进程的组成包括程序、数据和进程控制块PCB。说明程序是进程的一部分,是进程的实体。
- 进程与程序的对应关系:通过多次执行,一个程序可以对应多个进程;通过调用关系,一个进程可包括多个程序。
- 一个作业可以划分为若干个进程;每一个进程有其实体:程序和数据集合。
进程状态与控制
进程控制的主要任务:创建和撤销进程,以及实现进程的状态转换。由内核来实现
-
进程创建:
- 提交一个批处理作业
- 用户登录
- 由OS创建,用以向一个用户提供服务
- 由已存在的一进程创建
-
进程撤销:
- 用户退出登录
- 进程执行一个中止服务的请求
- 出错、失败
- 正常结束
- 给定时限到
-
原语:由若干条指令所组成的指令序列,来实现某个特定的操作功能
- 指令序列执行时连续的
- 是操作系统的核心组成部分
- 必须在内核态下执行
- 不可中断
-
创建原语:
fork
,exec
-
撤销原语:
kill
Fork()
函数使用举例
int main(){
pid_t fpid;
int count = 0;
fpid = fork();//创建了一个新进程,此时有两个进程在执行
if(fpid<0)
printf("err in fork!");
else if(fpid == 0){//在子进程中,fork函数返回0
printf("I am the child process,my process id is %d",getpid());
count++;
}
else{//在父进程中,fork函数返回新创建子进程的进程ID
printf("I am the parent process ,my process id is %d",getpid());
count++;
}
printf("统计结果是:%d/n",count);
}
根据fork函数返回的值,可以判断当前进程是父进程还是子进程。
fork
创建普通进程clone
创建线程kernel_thread
创建内核进程
进程的状态
- 就绪状态:进程已获得除处理机(CPU)外的所有所需资源
- 执行状态:占用处理机资源
- 阻塞状态:正在执行的进程,由于某种时间暂时无法执行,放弃处理机处于暂停状态

进程控制块PCB
作用:进程的创建和撤销;作为进程的唯一标识

PCB的组织方式
- 线性表:不论进程状态如何,将所有PCB连续地存放在内存的系统区。适用于进程数量不多的情况。
- 索引方式:系统按照进程的状态分别建立就绪索引表、阻塞索引表等。
- 链接方式:系统按照进程的状态将进程的PCB组成队列,从而形成就绪队列、阻塞队列、运行队列。
进程上下文切换vs陷入内核
进程上下文切换
一定会陷入内核。
- 通常由调度器执行
- 保存进程执行断点
- 切换内存映射
陷入/退出内核
不一定导致进程切换。
- CPU状态改变
- 由中断、异常、Trap指令引起
- 需要保存执行现场
- 消耗相对进程上下文切换小很多
线程概念的引入
进程的不足:
-
进程在一个时间只能处理一个任务
-
如果进程在执行时阻塞,整个进程都无法继续执行
在等待输入时,即使进程中有些处理不依赖于 输入数据,也将无法执行。
所以需要提出一种新的实体,满足:
- 实体之间可以并发地执行
- 实体之间共享相同的地址空间(进程拥有各自独立的地址空间)
实际上进程包含两个概念:资源拥有者和可执行单元
现代操作系统将资源拥有者称为进程,可执行单元称为线程
线程:将资源与计算分离,提高并发效率(线程之间共享了资源,分别计算)
不适合多线程的情况:计算量大,CPU负载高。
进程与线程的区别
-
并发执行
- 多进程:多个程序可以并发执行,改善资源使用率
- 多线程:并发粒度更细,并发性更好→线程可以提高进程内的开发程度
-
线程间可以共享资源
- 进程的资源(线程共享的资源):虚拟地址空间、进程映像、处理机保护、文件、IO
- 线程的私有资源:运行状态、上下文(如程序计数器)、执行栈
-
系统开销:
- 进程:创建/撤销时需要分配/回收大量资源
- 线程:由于资源共享,减少了许多开销。
引入线程的优势:线程很轻量,容易创建、撤销

线程的实现方式
用户级线程
线程在用户控件,通过library
模拟的thread
,不需要或仅需要极少的kernel
支持
- 用户级线程库的主要功能:创建和销毁线程;线程之间传递消息和数据;调度线程执行;保存和恢复线程上下文
- 上下文切换比较快,因为不用更改
page table
等。 - 典型例子:java,POSIX
- 优点:
- 线程切换与内核无关
- 线程调度由应用决定,容易优化
- 可运行在任何操作系统上,只需要线程库的支持。
- 不足:
- 很多系统调用会引起阻塞,内核会因此而阻塞所有相关概念的线程。
- 内核只能将处理器分配给进程,即使有多个处理器也无法实现一个进程中 多个线程的并行执行。
内核级线程(→多线程内核)
kernel有好几个分身,一个分身可以用来处理一个事件。
对于处理非同步事件很有用,kernel可对每个非同步事件产生一个分身来操作。
- 典型例:Linux,Windows XP
- 优点
- 内核可以在多个处理器上调度一个进程的多个线程 实现同步并行执行。
- 阻塞发生在线程级别。
- 内核中一些处理可通过多线程实现。
- 缺点
- 一个进程中的线程,切换需要内核参与,线程的切换涉及两个模式的切换:进程–进程;线程–线程。降低效率。(切换时需要陷入内核)
比较
- 内核级线程是OS内核可感知的,而用户级线程 是OS内核不可感知的。
- 用户级线程的创建、撤消和调度不需要OS内核 的支持,是在语言或用户库这一级处理的;而内 核级线程的创建、撤消和调度都需OS内核提供 支持,而且与进程的创建、撤消和调度大体是相 同的。
- 用户级线程执行系统调用指令时将导致其所属进 程的执行被暂停,而内核级线程执行系统调用指 令时,只导致该线程被暂停。
- 在只有用户级线程的系统内,CPU调度还是以进程为单位,处于运行状态的进程中的多个线 程,由用户程序控制线程的轮换运行;在有内核级线程的系统内,CPU调度则以线程为单位 ,由OS的线程调度程序负责线程的调度。
- 用户级线程的程序实体是运行在用户态下的程序,而内核级线程的程序实体则是可以运行在任何状态下的程序。
混合线程
有些系统同时支持用户线程和内核线程,由此 产生了不同的多线程模型,即实现用户级线程 和内核级线程的不同连接方式。
- Many-to-One:多对一
- One-to-One:一对一
- Many-to-Many:多对多
同步与互斥
基本概念
- 进程的三个特征:并发、共享、不确定
- 临界资源:一次仅允许一个进程访问的资源
- 临界区:每个进程中访问临界资源的代码称为临界区
进程互斥(间接制约关系)
- 两个或两个以上的进程,不能同时进入关于同一组共享资源的临界区。
- 进程互斥是进程间发生的一种间接性作用,一般是程序不希望的
- 无法限制访问者对资源的访问顺序,是无序访问
进程同步(直接制约关系)
- 系统各进程之间能有效地共享资源和相互合作,从而使程序的执行具有可再现性。
- 进程同步时进程间一种刻意安排的直接制约关系
- 在互斥的基础上,通过其它机制实现访问者对资源的有序访问。
临界区管理应该满足的条件
- 没有进程在临界区时,想进入临界区的进程可以进入
- 任何两个进程都不能同时进入临界区
- 任何一个进程进入临界区的请求应该在有限时间内得到满足
- 当一个进程运行在临界区外面时,不能妨碍其他进程进入临界区
机制设计时应遵循的准则
- 空闲让进:临界资源处于空闲状态,允许进程进入临界区
- 忙则等待:临界区有正在执行的进程,所有其他进程则不可以进入临界区。
- 有限等待:对要求访问临界区的进程,应保证在有限时间内进入自己的临界区,避免死等。 (受惠的是进程自己)
- 让权等待:当其他进程(长时间)不能进入自己的临界区时,应立即释放处理机,尽量避免忙等。(受惠的是其他进程)
忙等待:可以与自旋锁、轮询等同,进程不断申请进入临界区,直到被允许。像 while(judge)
让权等待:进程申请进入临界区,不被允许则睡眠(阻塞、等待)。像 sleep()
睡眠是阻塞的一种方式,睡眠的进程会sleep一段时间,醒来后继续运行。
两者比较,忙等待一直占用CPU,一直申请进入临界区操作,进程处于运行态;
让权等待申请一次后被拒,则主动让出CPU,进程处于阻塞态
基于忙等待的互斥方法
- 软件方法
- 硬件方案1:中断屏蔽
- 执行“关中断“指令,进入临界区操作。(不允许被其他指令中断)
- 退出临界区之前,执行“开中断“指令
- 使用范围:内核进程,不适合多CPU系统
- 会带来很大的性能损失
- 硬件方案2:
test and set
指令,如自旋锁Spinlocks
共性问题
- 忙等待:浪费CPU时间
- 优先级反转:
- 低优先级进程先进入临界区,高优先级进程一直忙等待;但是优先调度高优先级进程执行,导致两个进程都不会执行下去。
- 如果使用用户级线程,低优先级线程不会被高优先 级线程抢占,因为抢占发生在进程级别。但是对于内核级线程的实现,这个是可能发生的。
基于信号量的同步方法
解决忙等待的方法:将忙等待变为阻塞。可以使用原语:Sleep和Wakeup
显然Wakeup的调用需要一个参数:被唤醒的进程ID
信号量
- 信号量使用一个整型变量来累计唤醒次数;
- 程序对其访问是原子操作,且只允许对它进行P操作和V操作
信号量的定义
信号量是一个确定的二元组(s,q),其中:
- s是一个具有非负初值的整型变量。发出P操作时,该值可等于立即执行的进程数量;当s<=0时,发出P操作后的进程被阻塞,|s|是被阻塞的进程数
- q是一个初始状态为空的队列。发出P操作时,有进程被阻塞时就会进入此队列。
信号量的分类
-
二元信号量&一般信号量
-
二元信号量:取指仅为0或1,主要用于实现互斥
应用时应该注意:
-
每个进程中的PV操作必须成对出现,先P操作进入临界区,再V操作出临界区。
-
PV操作尽量紧靠头尾部。
-
互斥信号量初始值一般为1。
-
-
一般信号量:初始值为可用物理资源的总数,可用于进程的协作同步。
-
-
强信号量&弱信号量
- 强信号量:进程从被阻塞队列中释放时采用FIFO,不会出现“饥饿”(某个进程长时间被阻塞)
- 弱信号量:没有规定进程从阻塞队列中的移除顺序,可能出现“饥饿”。
一般信号量的结构

信号量的操作
-
一个信号量可能被初始化为一个非负整数
-
P操作使信号量
-1
。若值<0
(表示此类资源已被分配完毕),则执行P操作的进程被阻塞,否则进程继续执行 -
V操作使信号量
+1
,若值<=0
(表示此时仍然有进程在等待这类资源),则被P操作阻塞的进程解除阻塞都是先减/加,再判断范围。
信号量的应用
-
互斥(一P一V):用初始值为1的信号量来实现进程间的互斥。一个进程在进入临界区之前执行P操作,退出临界区时执行V操作。
-
有限并发:有n个进程并发执行一个函数/资源。使用一个初始值为c(c>=n)的信号量可以实现并发。
实际上有多少资源,就把初始值设为多少。申请资源时调用P,释放资源是调用V。
-
进程同步(先V后P ):进程P2想要执行⼀个a2操作时,它只在进程P1执行完a1后,才会执行a2操作。
将信号量初始值设为0,P1执行a1操作后,执行一个V操作;P2执行a2操作前,执行一个P操作。
如图中理解,进程同步的“前V”实际上是先产生某种后续需要的资源,所以初始为0;“后P”是对产生资源的使用。
-
前驱关系:(实际上是若干个进程同步)
PV操作的优缺点
- 优点:简单,可以解决任何同步互斥问题。
- 缺点:不够安全;使用不当会出现死锁;遇到复杂同步互斥问题时实现复杂。
信号量集机制
-
AND型
将进程需要的所有共享资源一次全部分配给它,进程使用完后再一起释放
-
一般信号量集
进程对信号量Si的测试值为ti,即Si >= ti,表示资源数量低于ti时,便不予分配;
占用值为di,用于信号量的增减,即Si = Si - di和Si = Si + di 。
管程
管程是一种高级同步机制;管道是单独构成一种独立的文件系统,并且只存在在内存中。
定义:一个管程定义了一个数据结构,和能为并发进程所执行的一组操作,这组操作能同步进程和改变管程中的数据。
组成:
- 名称
- 局部与管程内部的共享数据结构说明
- 对该数据结构进行操作的一组互斥执行的过程
- 对局部于管程内部的共享数据设置初始值的语句
条件变量:(与信号量的区别)
为了区别等待的不同原因,管程引入了条件变量。不同的条件变量对应不同原因的进程阻塞等待队列。
- 条件变量的值不可增减;
- wait操作一定会阻塞当前进程
- 如果没有等待的进程,signal将会丢失。
- 访问条件变量必须拥有管程的锁。
Hoare管程:
- 入口等待队列、紧急等待队列
- x.wait:先判断紧急等待队列,再判断入口等待队列
- x.signal:若x队列为空则相当于空操作;否则唤醒第一个等待者,执行x.signal()操作的进程排入紧急等待队列的尾部。
进程通信IPC
- 低级通信:只能传递状态和整数值(控制信息),包括进程互斥和同步所采用的信号量和管程机制。缺点:
- 传送信息量小,效率低。
- 编程复杂:用户直接实现通信的细节,编程复杂。
- 相同地址空间:属于同一个进程的多个线程/共享地址空间的多个进程
- 高级通信:适用于分布式系统,基于共享内存的多处理机系统、单处理机系统,能传送任何熟练的数据,可以解决进程的同步问题和通信问题。主要包括三类:管道、共享内存、消息系统。
管道:
- 无名管道Pipe:只支持亲缘关系进程;数据只能向一个方向流动;单独构成一种独立的文件系统,只存在于内存当中。写入的内容添加在管道末尾、从头部读出。
- 有名管道FIFO:没有限制;严格先进先出。
消息传递
- 使用两个通信原语:send和receive
- 主要解决消息丢失、延迟问题
共享内存:
- 共享内存的意义:同一块物理内存被映射到进程A、B各自的进程地址空间。
- 共享内存可以同时读但不能同时写,所以需要同步机制约束
- 通信效率高,是最有用也是最快的。
套接字
可以用于不同机器之间的进程通讯,也可以用于本机的两进程通讯。
经典同步互斥问题
生产者—消费者问题
若干进程通过有限的共享缓冲区交换数据,其中生产者进程不断写入,消费者进程不断取出(写操作);
共享缓冲区共有N个;
任何时刻只能有一个进程可对共享缓冲区进行操作。
- 隐含条件:
- 消费者和生产者数量不固定;
- 消费者和生产者不能同时使用缓冲区
- 行为关系:
- 生产者之间:互斥
- 消费者之间:互斥
- 生产者和消费者之间:互斥(放/取产品);同步(放置——取出)
PV操作解决
-
信号量设置:
semaphore mutex = 1
//互斥semaphore empty = N
//空闲数量semaphore full = 0
//产品数量 -
两个V交换顺序对语义无影响,但会降低效率。
Sleep和Wakeup原语解决
读者—写者问题
哲学家进餐问题
调度
CPU调度的任务是控制、协调多个进程对CPU的竞争。
调度的类型:
- 高级调度:又称“作业调度”,从用户角度,一次提交若干个作业,对每个作业进行调度。
- 中级调度:又称“内外存交换”,从存储器资源管理的角度,将进程的部分或全部换出到外存上,将当前所需的部分换入到内存。
- 低级调度:又称”进程或线程调度“,从CPU资源管理的角度调度执行的单位。
性能准则:
- 面向对象的性能准则1:
- 周转时间:作业从提交到完成所经历的时间——批处理系统
- 响应时间:用户从输入一个请求到系统给出首次响应的时间——分时系统
- 面向对象的性能准则2:
- 截止时间:开始截止时间和完成截止时间--实时系统,与周转时间有些相似。
- 优先级:可以使关键任务达到更好的指标
- 公平性:不因作业或进程本身调度特性而使上述指标过分恶化。
- 面向系统的调度
- 吞吐量:单位时间内完成的作业数——批处理系统。
- 处理机利用率
- 各种资源均衡利用
占用CPU的方式:
- 不可抢占式
- 抢占式
执行时间 = 运行时间。周转时间 = 执行时间+等待时间。
批处理系统的调度算法
无需与用户交互,也不需要很快地响应;
如编译器、科学计算等。
- FCFS 先来先服务:有利于CPU繁忙的作业,不利于IO繁忙的作业。
- SJF 最短作业优先:提高系统的吞吐量,改善平均周转和平均带权周转。
- SRTF 最短剩余时间优先
- HRRF 最高响应比优先
- 每次选择作业投入运行时,先计算后备作业队列中每个作业的响应比RP,选择最大的作业投入运行。
- 等得越久、工作时间越短,越容易被投入运行
- 非抢占式,饥饿现象不会发生
- 但是每次计算响应比会有一定的时间开销。
交互式系统的调度算法
与用户交互频繁,长时间等待用户输入,响应时间要快
如WPS、GUI等。
-
时间片轮转RR(最常用)
- 时间片过长——>退化为FCFS算法,过短:响应时间长
- 数目越多,时间片应该越小。
- 应当使用户输入在一个时间片内能处理完。
-
多级队列
-
多级反馈队列
- 设置多个就绪队列,分别赋予不同的优先级,队列1的优先级最高。规定每个队列优先级越低,时间片越长。
- 新进程进入内存后,先投入队列1的末尾,按FCFS算法调度;若一个队列1的时间片内未完成,则降低投入到队列2的末尾,如此循环直到完成。
- 仅当高优先级的队列为空时,才调度较低优先级的队列中的进程执行。如果进程执行时有新进程进入较高 优先级的队列,则抢先执行新进程,并把被抢先的进程投入原队列的末尾。
实时系统的调度算法
有实时要求,不能被低优先级进程阻塞;响应时间要短且稳定。
如视频、音频、控制类。
实时系统是一种实践起着主导作用的系统,当外部物理设备想计算机发出一个信号,要求计算机必须在确定的时间范围内恰当地给出反应。
实时系统通常将对不同刺激的响应指派给不同的进程,且每个进程的行为是可预测的。

实时调度算法:
-
静态表调度
通过对所有周期性任务的分析预测,事先固定一个调度方案
-
单调速率调度RMS
任务的周期越小,其优先级越高。
任务集可调度的充分必要条件:
抢占式。
-
最早截止时间优先算法EDF
任务绝对截止时间越早,其优先级越高。
任务集可调度的充分必要条件:
谁的DDL最急,谁先跑。
抢占式。
-
最低松弛度优先算法LLF
松弛度 = 任务截止时间- 本身剩余运行时间- 当前时间
任务集可调度的充分必要条件:
死锁
- 死锁定义:一组进程中,每个进程都无限等待组内其他进程所占有的资源,在无外力介入的条件下,将因永远分配不到资源而无法运行的现象。
- 资源的种类:可剥夺资源;非可剥夺资源;临时性资源。
- 死锁发生的四个必要条件:
- 互斥条件
- 请求和保持资源
- 不可剥夺条件
- 环路等待条件
其他一些定义:
- 活锁:任务或执行者没有被阻塞,由于某些条件没有被满足,导致一直重复尝试。
- 与死锁的区别:处于活锁的实体是在不断地改变状态,而处于死锁的实体表现为等待;活锁有可能自行解开,死锁不能。
- 避免活锁:先来先服务策略。
- 饥饿:某些进程可能由于资源分配策略导致长时间等待。
处理死锁的方法
- 不允许死锁发生:预防死锁(静态)、避免死锁(动态)
- 允许死锁发生:检测与解除死锁;无所作为:鸵鸟算法。
死锁预防
- 打破互斥条件
- 打破请求且保持的条件
- 打破不可剥夺条件
- 打破循环等待条件
死锁避免
不限制有关资源的申请,而是对进程锁发出的每一个申请资源命令加以动态的检查,根据检查结果决定是否进行资源分配。
安全序列
定义:一个序列{P1,P2……,Pn}是安全的,是指若对于每一个进程Pi,它需要的资源可以被系统中当前可用资源加上所有所有进程Pj(j<i)当前占有的资源之和所满足。
银行家算法
判断一次资源请求分配是否可行。
特点:允许互斥、部分分配和不可抢占,可以提高资源利用率。
过程:
- 如果request_i<=need_i,转向2,否则认为出错
- 如果request_i<=Available,转向3,否则Pi必须等待
- 系统试探分配,同时修改Available等数值
- 系统执行安全性算法,判断分配后是否处于安全状态;
- 若安全则正式将资源分配给Pi
- 不安全则试探分配作废,Pi继续等待。
安全性算法
判断系统是否处于安全状态。
过程:
分配所需——释放全部——标记已完成
不断循环,直到所有进程的Finish=true。
相当于找一个安全序列。
死锁检测
保存资源的请求和分配信息,利用某种算法对这些信息加以检查,以判断是否存在死锁。死锁检测算法主要是检查是否有循环等待。
资源分配图
用有向图描述系统资源和进程的状态。用圆圈代表进程,矩形代表一类资源,矩形中的小圈代表每个资源;有向边代表资源的分配和请求。
- 存在死锁一定存在环路:
- 但有环路不一定有死锁:
封锁进程:某个进程由于请求了超过系统中现有的未分配资源数目的资源,而被系统封锁的进程
非封锁进程:就是没有被封锁的进程
资源分配图的化简方法:
假设某个RAG中存在一个非封锁进程Pi:
- 当Pi有请求边时,首先将请求边变成分配边,一旦Pi的所有资源请求都得到满足,就删去这些分配边。
死锁定理:
系统中某个时刻t为死锁状态的充要条件是t时刻系统的资源分配图是不可完全化简的。
完全化简:在经过一系列的简化后,若能消去图中的所有边,式所有的进程都成为孤立节点,则称该图是可完全化简的。
死锁解除
- 剥夺资源:挂起/激活一些进程,剥夺它们的资源以解除死锁
- 撤销进程
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· 上周热点回顾(2.17-2.23)