前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >「笔记」理解Linux进程

「笔记」理解Linux进程

作者头像
王诗翔呀
发布2021-04-23 11:01:00
7990
发布2021-04-23 11:01:00
举报
文章被收录于专栏:优雅R

原文项目:https://github.com/tobegit3hub/understand_linux_process[1]原文阅读地址:https://tobegit3hub1.gitbooks.io/understanding-linux-processes/content/[2]

进程(Process)是计算机中已运行程序的实体。用户下达运行程序的命令后,就会产生进程。每个CPU核心任何时间内仅能运行一项进程。

PID 和 PPID

PID全称Process ID,是标识和区分进程的ID,它是一个全局唯一的正整数。每个进程除了一定有PID还会有PPID,也就是父进程ID,通过PPID可以找到父进程的信息。

跟人类起源问题一样,父进程的父进程的父进程又是什么呢?实际上有一个PID为1的进程是由内核创建的init进程,其他子进程都是由它衍生出来,所以前面的描述并不准确,进程号为1的进程并没有PPID。因为所有进程都来自于一个进程,所以Linux的进程模型也叫做进程树。

Golang 代码:

代码语言:javascript
复制
package main

import (
 "fmt"
 "os"
)

func main() {
 fmt.Println("PID: ", os.Getpid())
 fmt.Println("PPID", os.Getppid())
}

结果:

代码语言:javascript
复制
$ go run "/Users/wsx/Documents/GitHub/self-study/go/prac/pid.go"
PID:  94788
PPID 94771

查看和使用 PID

使用 top 命令后,第一列显示的就是 PID。

ps 命令也可以查到 PID 信息:

一般我们想要杀死某个程序时会用到 PID,通过kill命令来结束进程,也可以通过 kill -9或其他数字向进程发送不同的信号

进程名字

每个进程都一定有进程名字,例如我们运行top,进程名就是“top”,如果是自定义的程序呢?其实进程名一般都是进程参数的第一个字符串,在Go中可以这样获得进程名。

代码语言:javascript
复制
package main

import (
 "fmt"
 "os"
)

func main() {
 processName := os.Args[0]
 fmt.Println(processName)
}

查看输出:

代码语言:javascript
复制
$ go run "/Users/wsx/Documents/GitHub/self-study/go/prac/pname.go"
/var/folders/bj/nw1w4g1j37ddpgb6zmh3sfh80000gn/T/go-build836639686/b001/exe/pname

进程参数

任何进程启动时都可以赋予一个字符串数组作为参数,一般名为 ARGV 或 ARGS。

进程参数一般可分为两类,一类是程序名,一类是Argument,也就是作为进程运行的实体参数。例如,cp config.yml config.yml.bakcp 是程序名,后 2 个是传入程序的实体参数。

代码语言:javascript
复制
package main

import (
 "fmt"
 "os"
)

func main() {
 argsWithProg := os.Args
 argsWithoutProg := os.Args[1:]

 arg := os.Args[3]
 fmt.Println(argsWithProg)
 fmt.Println(argsWithoutProg)
 fmt.Println(arg)
}

查看输出:

代码语言:javascript
复制
$ go run ./cla.go a b c d e
[/var/folders/bj/nw1w4g1j37ddpgb6zmh3sfh80000gn/T/go-build409118155/b001/exe/cla a b c d e]
[a b c d e]
c

进程参数只有在启动进程时才能赋值,如果需要在程序运行时进行交互,就需要了解进程的输入与输出了。

进程输入输出

每个进程操作系统都会分配三个文件资源,分别是标准输入(STDIN)、标准输出(STDOUT)和错误输出(STDERR)(代号分别为0、1、2)。通过这些输入流,我们能够轻易得从键盘获得数据,然后在显示器输出数据。

标准输入

来自管道(Pipe)的数据也是标准输入的一种:

代码语言:javascript
复制
package main

import (
 "fmt"
 "io/ioutil"
 "os"
)

func main() {
 bytes, err := ioutil.ReadAll(os.Stdin)
 if err != nil {
  panic(err)
 }
 fmt.Println(string(bytes))
}

运行:

代码语言:javascript
复制
$ echo 123 | go run ./stdin.go 
123
标准输出

上面输出的 123 就是标准输出。

错误输出

程序的错误输出与标准输出类似,一般是程序打印的错误信息会输出到错误输出中。

并发与并行

并发(Concurrently)和并行(Parallel)是两个不同的概念。借用 Go 创始人 Rob Pike 的说法,并发不是并行,并发更好。并发是一共要处理很多事情,并行是一次可以做多少事情。

举个简单的例子,华罗庚泡茶,必须有烧水、洗杯子、拿茶叶等步骤。现在我们想尽快做完这件事,也就是“一共要处理很多事情”,有很多方法可以实现并发,例如请多个人同时做,这就是并行。并行是实现并发的一种方式,但不是唯一的方式。我们一个人也可以实现并发,例如先烧水、然后不用等水烧开就去洗杯子,所以通过调整程序运行方式也可以实现并发。

进程状态

我们知道进程是代码运行的实体,而进程有可能是正在运行的,也可能是已经停止的,这就是进程的状态。可以查看 Linux 源码中定义的进程状态:

代码语言:javascript
复制
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char * const task_state_array[] = {
  "R (running)",        /*   0 */
  "S (sleeping)",        /*   1 */
  "D (disk sleep)",    /*   2 */
  "T (stopped)",        /*   4 */
  "t (tracing stop)",    /*   8 */
  "X (dead)",        /*  16 */
  "Z (zombie)",        /*  32 */
};

通过ps aux可以看到进程的状态。O:进程正在处理器运行,这个状态从来没有见过。S:休眠状态(sleeping) R:等待运行(runable)R Running or runnable (on run queue) 进程处于运行或就绪状态。I:空闲状态(idle) Z:僵尸状态(zombie) T:跟踪状态(Traced) B:进程正在等待更多的内存页 D: 不可中断的深度睡眠,一般由IO引起,同步IO在做读或写操作时,cpu不能做其它事情,只能等待,这时进程处于这种状态,如果程序采用异步IO,这种状态应该就很少见到了。

其中就绪状态表示进程已经分配到除CPU以外的资源,等CPU调度它时就可以马上执行了。运行状态就是正在运行了,获得包括CPU在内的所有资源。等待状态表示因等待某个事件而没有被执行,这时候不耗CPU时间,而这个时间有可能是等待IO、申请不到足够的缓冲区或者在等待信号。

退出码

任何进程退出时,都会留下退出码,操作系统根据退出码可以知道进程是否正常运行。退出码是0到255的整数,通常0表示正常退出,其他数字表示不同的错误

代码语言:javascript
复制
$ cat yes
cat: yes: No such file or directory                                                                                                                    ⍉
$ echo $? # 获取上一条命令退出码
1

image.png

进程文件

在Linux中“一切皆文件”,进程的一切运行信息(占用CPU、内存等)都可以在文件系统找到,例如看一下PID为1的进程信息。(MacOS上无法操作)

代码语言:javascript
复制
[email protected]:/go/src# ls /proc/1/
attr        cmdline          cwd      fdinfo   loginuid   mounts      numa_maps      pagemap      sessionid  status   wchan
auxv        comm             environ  gid_map  maps       mountstats  oom_adj        personality  smaps      syscall
cgroup      coredump_filter  exe      io       mem        net         oom_score      projid_map   stat       task
clear_refs  cpuset           fd       limits   mountinfo  ns          oom_score_adj  root         statm      uid_map

我们可以看一下它的运行状态,通过cat /proc/1/status即可。

代码语言:javascript
复制
[email protected]:/go/src# cat /proc/1/status
Name:   bash
State:  S (sleeping)
Tgid:   1
Ngid:   0
Pid:    1
PPid:   0
TracerPid:      0
Uid:    0       0       0       0
Gid:    0       0       0       0
FDSize: 256
Groups:
VmPeak:    20300 kB
VmSize:    20300 kB
VmLck:         0 kB
VmPin:         0 kB
VmHWM:      3228 kB
VmRSS:      3228 kB
VmData:      408 kB
VmStk:       136 kB
VmExe:       968 kB
VmLib:      2292 kB
VmPTE:        60 kB
VmSwap:        0 kB
Threads:        1
SigQ:   0/3947
SigPnd: 0000000000000000
ShdPnd: 0000000000000000
SigBlk: 0000000000010000
SigIgn: 0000000000380004
SigCgt: 000000004b817efb
CapInh: 00000000a80425fb
CapPrm: 00000000a80425fb
CapEff: 00000000a80425fb
CapBnd: 00000000a80425fb
Seccomp:        0
Cpus_allowed:   1
Cpus_allowed_list:      0
Mems_allowed:   00000000,00000001
Mems_allowed_list:      0
voluntary_ctxt_switches:        684
nonvoluntary_ctxt_switches:     597

死锁

死锁(Deadlock)就是一个进程拿着资源A请求资源B,另一个进程拿着资源B请求资源A,双方都不释放自己的资源,导致两个进程都进行不下去。我们可以写代码模拟进程死锁的例子。

代码语言:javascript
复制
package main
func main() {
  ch := make(chan int)
  <-ch
}
代码语言:javascript
复制
$ go run "/Users/wsx/Documents/GitHub/self-study/go/prac/deadlock.go"
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
 /Users/wsx/Documents/GitHub/self-study/go/prac/deadlock.go:5 +0x4d
exit status 2

这里Go虚拟机已经替我们检测出死锁的情况,因为所有Goroutine都阻塞住没有运行。

活锁

进入活锁的进程是没有阻塞的,会继续使用CPU,但外界看到整个进程都没有前进。举个很简单的例子,两个人相向过独木桥,他们同时向一边谦让,这样两个人都过不去,然后二者同时又移到另一边,这样两个人又过不去了。如果不受其他因素干扰,两个人一直同步在移动,但外界看来两个人都没有前进,这就是活锁。活锁会导致CPU耗尽的,解决办法是引入随机变量、增加重试次数等。

POSIX

POSIX(Portable Operation System Interface)听起来好高端,就是一种操作系统的接口标准,至于谁遵循这个标准呢?就是大名鼎鼎的Unix和Linux了,有人问Mac OS是否兼容POSIX呢,答案是Yes苹果的操作系统也是Unix-based的。有了这个规范,你就可以调用通用的API了,Linux提供的POSIX系统调用在Unix上也能执行,因此学习Linux的底层接口最好就是理解POSIX标准。我们运行Hello World程序时,操作系统通过POSIX定义的forkexec接口创建起一个POSIX进程,这个进程就可以使用通用的IPC、信号等机制。POSIX也定义了线程的标准,包括创建和控制线程的API。

nohup

Nohup的原理很简单,终端关闭后会给此终端下的每一个进程发送SIGHUP信号,而使用nohup运行的进程则会忽略这个信号,因此终端关闭后进程也不会退出。

Go 进程编程

Go 能够执行任意 Go 或者非 Go 程序,并且等待放回结果,外部进程结束后继续执行 Go 程序。

衍生进程

如果你的程序需要执行外部命令,可以直接使用exec.Command()来Spawn(衍生)进程,并且根据需要获得外部程序的返回值。

代码语言:javascript
复制
package main

import (
 "fmt"
 "io/ioutil"
 "os/exec"
)

func main() {
 dateCmd := exec.Command("date")
 dateOut, err := dateCmd.Output()
 if err != nil {
  panic(err)
 }
 fmt.Println("> date")
 fmt.Println(string(dateOut))

 grepCmd := exec.Command("grep", "hello")
 grepIn, _ := grepCmd.StdinPipe()
 grepOut, _ := grepCmd.StdoutPipe()
 grepCmd.Start()
 grepIn.Write([]byte("hello grep\ngoodbye grep"))
 grepIn.Close()
 grepBytes, _ := ioutil.ReadAll(grepOut)
 grepCmd.Wait()
 fmt.Println("> grep hello")
 fmt.Println(string(grepBytes))

 lsCmd := exec.Command("bash", "-c", "ls -a -l -h")
 lsOut, err := lsCmd.Output()
 if err != nil {
  panic(err)
 }
 fmt.Println("> ls -a -l -h")
 fmt.Println(string(lsOut))
}
执行外部程序

与Spawn不同,执行外部程序并不会返回到原进程中,也就是让外部程序完全取代本进程。

代码语言:javascript
复制
package main

import "syscall"
import "os"
import "os/exec"

func main() {
    binary, lookErr := exec.LookPath("ls")
    if lookErr != nil {
        panic(lookErr)
    }
    args := []string{"ls", "-a", "-l", "-h"}
    env := os.Environ()
    execErr := syscall.Exec(binary, args, env)
    if execErr != nil {
        panic(execErr)
    }
}

进程进阶

进程锁

它是通过记录一个PID文件,避免两个进程同时运行的文件锁。其实要实现一个进程锁很简单,通过文件就可以实现了。例如程序开始运行时去检查一个PID文件,如果文件存在就直接退出,如果文件不存在就创建一个,并把当前进程的PID写入文件中。这样我们很容易可以实和读锁,但是所有流程都需要自己控制。当然根据DRY(Don't Repeat Yourself)原则,Linux已经为我们提供了flock接口。

孤儿进程

孤儿进程指的是在其父进程执行完成或被终止后仍继续运行的一类进程。也就是父进程不在了,子进程还在运行。在现实中用户可能刻意使进程成为孤儿进程,这样就可以让它与父进程会话脱钩,成为后面会介绍的守护进程

僵尸进程

当一个进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。

守护进程

我们可以认为守护进程就是后台服务进程,因为它会有一个很长的生命周期提供服务,关闭终端不会影响服务,也就是说可以忽略某些信号。

进程间通信

  • 管道:管道是进程间通信最简单的方式,任何进程的标准输出都可以作为其他进程的输入。
  • 信号。
  • 消息队列。
  • 信号量:本质上是一个整型计数器,调用wait时计数减一,减到零开始阻塞进程,从而达到进程、线程间协作的作用。
  • 套接字:通过网络来通信,这也是最通用的IPC,不要求进程在同一台服务器上。
信号

信号只是告诉进程发生了什么事件,而不会传递任何数据。Linux中定义了很多信号,不同的Unix-like系统也不一样,我们可以通过下面的命令来查当前系统支持的种类。

代码语言:javascript
复制
$ kill -l
HUP INT QUIT ILL TRAP ABRT EMT FPE KILL BUS SEGV SYS PIPE ALRM TERM URG STOP TSTP CONT CHLD TTIN TTOU IO XCPU XFSZ VTALRM PROF WINCH INFO USR1 USR2

其中1至31的信号为传统UNIX支持的信号,是不可靠信号(非实时的),32到63的信号是后来扩充的,称做可靠信号(实时信号)。不可靠信号和可靠信号的区别在于前者不支持排队,可能会造成信号丢失,而后者不会。在命令行中止一个程序我们一般摁Ctrl+C,这就是发送SIGINT信号,而使用kill命令呢?默认是SIGTERM,加上-9参数才是SIGKILL。

系统调用

我们要想启动一个进程,需要操作系统的调用(system call)。实际上操作系统和普通进程是运行在不同空间上的,操作系统进程运行在内核态(kernel space),开发者运行的进程运行在用户态(user space),这样有效规避了用户程序破坏系统的可能。如果用户态进程想执行内核态的操作,只能通过系统调用。

文件描述符

Linux很重要的设计思想就是一切皆文件,网络是文件,键盘等外设也是文件。内核给每个访问的文件分配了文件描述符(File Descriptor),它本质是一个非负整数,在打开或新建文件时返回,以后读写文件都要通过这个文件描述符了。

POSIX已经定义了STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO三个常量,也就是0、1、2。这三个文件描述符是每个进程都有的,这也解释了为什么每个进程都有编号为0、1、2的文件而不会与其他进程冲突。

文件描述符帮助应用找到这个文件,而文件的打开模式等上下文信息存储在文件对象中,这个对象直接与文件描述符关联。

注意了,每个系统对文件描述符个数都有限制。我们网上看到配置ulimit也是为了调大系统的打开文件个数,因为一般服务器都要同时处理成千上万个起请求,记住socket连接也是文件哦,使用系统默认值会出现莫名奇怪的问题。

Epoll

Epoll是poll的改进版,更加高效,能同时处理大量文件描述符,跟高并发有关,Nginx就是充分利用了epoll的特性。Poll本质上是Linux系统调用,其接口为int poll(struct pollfd *fds,nfds_t nfds, int timeout),作用是监控资源是否可用。举个例子,一个Web服务器建了多个socket连接,它需要知道里面哪些连接传输发了请求需要处理。poll会轮询整个文件描述符集合,而epoll可以做到只查询被内核IO事件唤醒的集合,当然它还提供边沿触发(Edge Triggered)等特性。简单来说epoll是基于文件描述符的callback函数来实现的,只有发生IO时间的socket会调用callback函数,然后加入epoll的Ready队列。

共享内存

不同进程之间内存空间是独立的,也就是说进程不能访问也不会干扰其他进程的内存。如果两个进程希望通过共享内存的方式通信呢?可以通过mmap()系统调用实现。

Copy on write

一般我们运行程序都是Fork一个进程后马上执行Exec加载程序。Copy On Write的含义是只有真正写的时候才把数据写到子进程的数据,Fork时只会把页表复制到子进程,这样父子进程都指向同一个物理内存页,只有在写子进程的时候才会把内存页的内容重新复制一份。

Cgroups

Cgroups全称Control Groups,是Linux内核用于资源隔离的技术。目前Cgroups可以控制CPU、内存、磁盘访问。我们首先在文件系统创建Cgroups组,然后修改这个组的属性,启动进程时指定加入的Cgroups组,这样进程相当于在一个受限的资源内运行了。Cgroups是Docker容器技术的基础,另一项技术是大名鼎鼎的Namespaces。

Namespace

Namespaces是容器技术的基础,因为有了命名空间的隔离,才能限制容器之间的进程通信,像虚拟内存对于物理内存那样,开发者无需针对容器修改已有的代码。Linux内核提供了clone系统调用,创建进程时使用clone取代fork即刻创建同一命名空间下的进程。

参考资料

[1]

https://github.com/tobegit3hub/understand_linux_process: https://github.com/tobegit3hub/understand_linux_process

[2]

https://tobegit3hub1.gitbooks.io/understanding-linux-processes/content/: https://tobegit3hub1.gitbooks.io/understanding-linux-processes/content/

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-04-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 优雅R 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • PID 和 PPID
  • 查看和使用 PID
  • 进程名字
  • 进程参数
  • 进程输入输出
    • 标准输入
      • 标准输出
        • 错误输出
        • 并发与并行
        • 进程状态
        • 退出码
        • 进程文件
        • 死锁
        • 活锁
        • POSIX
        • nohup
        • Go 进程编程
          • 衍生进程
            • 执行外部程序
            • 进程进阶
              • 进程锁
                • 孤儿进程
                  • 僵尸进程
                    • 守护进程
                    • 进程间通信
                      • 信号
                        • 系统调用
                          • 文件描述符
                            • Epoll
                              • 共享内存
                                • Copy on write
                                  • Cgroups
                                    • Namespace
                                    • 参考资料
                                    相关产品与服务
                                    容器服务
                                    腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
                                    领券
                                    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档