这一章主要为进程的基本内容作一个总结,为后面的多进程多线程并发作一个铺垫。
进程标识符pid
pid类型为 pid_t。
在涉及有关进程相关内容的时候,一定要熟悉 ps 命令的使用,该命令专门用来打印当前系统的进程信息:
这里经常使用:
ps axf:描述当前系统的所有进程,展示其pid、tty(终端)、STAT和TIME(消耗时间)以及COMMAND(什么命令触发的)
ps axm:以详细信息来进行查看进程内容,在多线程并发时常用
ps ax -L:以Linux特有的方式来查看进程信息
之前说过文件描述符的编号一定是优先使用当前范围内最小的号来挨个占用的,对于进程号则是顺次往下使用的。
对于pid有两个系统调用需要掌握:getpid() 和 getppid()
getpid返回当前进程号,getppid返回当前进程的父进程号。
进程的产生
进程的产生涉及一个非常重要的系统调用:fork()
fork的作用就是创建一个新进程,这个新进程是由当前进程复制过来的。
复制意味着一模一样,连执行的位置都一模一样;但也不会是完全一样,上面的第二张图展示了其不一样的地方。
我们这里直接总结一下比较重要的fork后父子进程的不同点:
1、fork的返回值不一样,pid不同,ppid也不同
2、未决信号和文件锁不继承,资源利用量归零
3、init进程:其pid为1号,是所有进程的祖先进程
我们来写个程序:
从上图运行结果可以看到,我们执行了三次程序,前两次一模一样,但是第三次光标停止不动了!
仔细观察可以看到在第三次执行中,我们的终端命令行已经在子进程打印“Child is working”之前先打印了,所以它不动了,继续输入命令其实是正常的。
这里其实就已经涉及到了多进程并发问题了嗷,Shell终端是个进程,我们的父进程和子进程加起来总共三个进程,它们仨是并发执行的。
所以不要猜测父子进程谁先被调度执行,因为并发情况下谁都可能先被调度。
调度器的调度策略来决定哪个进程先运行。
我们在源程序中在最后位置写个getchar让程序停在这一句代码,然后新开终端来查看其进程信息:
可以看到shell进程和./a.out进程(有两个),然后1268610是1268611的父进程。
这种有明显阶梯关系的都是明显的父子进程关系,但是顶格写的那些进程则都是由 init 1号进程创建的,即它们的父进程都是1号进程:
所以init 进程并非是所有进程的父进程,但一定是所有进程的祖先进程。
后面提到资源释放问题时,孤儿进程僵尸进程类似的进程其父进程消亡时是会被init 进程来接管的,此时这些进程的阶梯关系消失然后顶格写,就跟上图的这些进程一样。
有一个非常值得注意的问题,来看下面的程序:
我们执行了好几次,发现begin永远都只会输出一次,而end会输出两次,我们将其输入到文件当中试一下:
而写入到文件当中就会输出两次begin!
当我们把程序当中的Begin后面的\n换行符给去掉,终端也开始输出两次Begin了:
我们该如何解决这样的问题?答案是在fork之前,刷新所有成功打开的流文件:
可以看见此时就正常了,不论往终端设备写还是往文件里写,Begin都应该合理的只出现一次。
那么为什么加了\n在终端上打印是一个begin,而重定向到文件当中就出现了两个begin,去掉\n之后又能在终端上打印两个begin?
这是因为往终端设备上写内容时,终端设备默认是行缓冲模式,加上了\n会刷新缓冲区,而写到文件里时文件默认是全缓冲模式,在全缓冲模式里\n不再代表刷新行缓冲了,只单纯代表要换行。也就是说在写入文件时,Begin还在缓冲区内(全缓冲嘛,要等所有的内容都在缓冲区内写完之后才全部同步写到文件里),此时进行了 fork 操作,此时出现两个进程,父子进程内各自有一句Begin,所以就会输出两次Begin,同时我们也可以看到输出的两次Begin的进程号都是父进程的进程号,这是因为只在父进程里写了这句话!
而在终端设备上,因为其默认为行缓冲,所以不加 \n 时,该程序在终端设备上一样是输出两次,因为没有刷新行缓冲,所以不管是在终端设备还是写入文件时Begin都出现两次。加上 \n 时在终端设备就相当于会自动被刷新行缓冲,所以此时Begin那一句输出的缓存已经被刷新写到了输出设备上,自然在终端设备上就只出现了一次,而写入文件时又是写两次。
fork父子进程的关系
fork的写时复制机制
进程的消亡以及释放资源
在这一节中主要介绍两个系统调用:wait() 和 waitpid():
这两个函数都是用来替进程收尸的,通过这两个系统调用就可以将进程的资源回收起来。
而在所有提供的options中,最好用的是WNOHANG:
如果options写0的话,就意味着死等固定pid的那个进程结束,那就和wait操作没啥区别了(除了wait操作并不指定是哪个进程之外)。
对于pid也并不简单:
如果写成 waitpid(-1,&status,0) 这效果就等同于 wait(&status)了。
另外在多进程并发中工程领域常使用一种交叉分配进程的方法来提高系统效率和性能。该方法的基本思路是将任务或工作量轮流分配给不同的进程或线程,以实现负载均衡和资源充分利用的目标。
具体实现方法如下:
创建三个进程或线程,分别称为进程0、进程1和进程2。
给定一段区间,例如从任务0到任务2,将任务轮流分配给三个进程。具体来说,任务0分配给进程0,任务1分配给进程1,任务2分配给进程2。然后任务3再次分配给进程0,任务4分配给进程1,任务5分配给进程2,以此类推。当一个进程完成其任务后,它会等待下一个任务的分配。如果一个进程在等待一段时间后仍然没有分配到新的任务,则该进程被终止并释放其占用的资源。
如果一个任务需要执行较长时间,那么它会被分配给一个空闲的进程。这样可以平衡负载并避免某个进程过载的情况。
如果一个任务需要与其他任务进行通信或共享数据,那么它可以通过消息传递或共享内存等方式与其他进程进行通信。这样可以实现并发执行中的协同工作。
交叉分配进程的方法可以提高系统效率和性能,因为它可以平衡负载并充分利用系统资源。此外,该方法还可以提高系统的可靠性和稳定性,因为当一个进程出现故障时,其他进程可以继续执行任务并保证系统的正常运行。
实际工程当作百分之九十以上的场景都是使用这种分配进程的方法,需要有个印象,后面会用到。
exec函数族
先来看一个问题:
之前我们说到这个当使用fork生成子进程时,子进程是父进程的复制品,所以和父进程一模一样,那么如上图所示,我们知道./primer2也是bash的子进程啊,那么为什么它长得和bash一点不一样呢?
即为什么shell创建的子进程不是shell而是上图的primer2.
这就涉及到了我们的exec函数族的使用:
这套函数的作用都是用来执行一个二进制可执行文件用的。
这句话是对这套函数族作用的点睛之笔:这套函数族会用一个新的进程映像来替换当前的进程映像。
这就是为什么我们在shell下面使用./primer2然后能够产生一个子进程并且这个子进程是primer2而不是shell,就是因为这套函数的存在,在这个例子下它们的功能就是用新的进程映像primer2来替换当前进程shell。
接下来对这套函数族作一个解释:
我们来写一个小例子感受一下:
再来一个更加综合的小例子,集合了fork、exec和wait的操作:
是不是感觉打通了任督二脉,基本上这就是shell命令的雏形呀!
比如 ls 命令在 shell 环境下被执行的时候具体发生了什么事情?
在 Shell 环境下执行 ls 命令时,shell 会 fork 产生一个子进程,但其实这个子进程也就是 shell 本身,但在 fork 时还会执行刚刚说的 exec 函数族让 shell 子进程摇身一变成为 ls 进程,此时的 shell 父进程就会执行 wait 一直等待子进程的结束。
这也就是为什么我们使用 shell 的时候总是执行完命令如 ls ,先打印出来了 ls 的内容然后命令行再弹出来又继续等待输入。
接下来我们再写一个让子进程sleep100的程序:
此时父进程正在等待子进程,我们新开终端去查看进程树的关系:
可以看见和我们之前说的一样。
另外我们其实并不关心argv[0]是什么,这会造成一定的问题,比如上面的程序,如果我们让exec的argv[0]参数变成httpd,在进程树中它就会变成httpd:
可以看到我们写什么系统展示的就是什么,这实际上就是黑客的一种比较低级的木马技术,当我们感觉OS有点问题或者文件安全出现了问题的时候,我们一去查进程关系,看到httpd这名字会觉得这里有个http服务器在跑肯定不是这个的问题,那么它就被混过去了,我们也就无法发现,这其实是很危险且具有迷惑性的。
当然高级的方式还有藏在内核中的,这里不再赘述。
小程序:实现一个简易myshell
运行结果如下:
怎么样是不是和我们的shell很像呢?
只不过现在这个shell只能执行外部命令,内部命令的处理需要更加多的知识,以后就会知道啦,后面再来完善,但现在我们已经可以做一个有趣的事情了,就是我们系统用户登录之所以默认的bash shell,是因为系统写的命令是这样的,使用vim /etc/passwd可以查看:
可以看到这两个用户用的都是bash shell,那么现在我们将john用户的登录shell改成我们的shell:
然后我们登录john这个用户时:
可以看见正常使用。
用户权限以及组权限
这一块内容主要是解释 u+s 和 g+s 是怎么实现的:
在Linux中,u+s和g+s是文件权限的表示方式。其中,u表示用户,g表示组。
u+s表示为用户添加set-user-ID位。当文件被执行时,进程的有效用户ID将变为该文件的用户ID。这允许用户在执行文件时获得相应的权限。例如,如果一个文件的用户ID是root,那么当普通用户执行该文件时,该进程将具有与root用户相同的权限。
g+s表示为用户组添加set-group-ID位。当文件被执行时,进程的有效用户组ID将变为该文件的组ID。这允许用户组中的所有用户在执行文件时获得相应的权限。例如,如果一个文件的组ID是root,那么当普通用户执行该文件时,该进程的有效用户组ID将变为root组,从而允许该进程访问root组所拥有的文件和资源。
u+s和g+s的实现原理是在文件权限中设置了相应的位。在Linux中,每个文件都有一个权限位,用于指示文件的拥有者、所属组和其他用户的访问权限。当设置了u+s或g+s时,系统将在执行文件时将进程的有效用户ID或有效用户组ID设置为相应的值,从而允许进程访问相应的文件和资源。
需要注意的是,使用u+s和g+s需要谨慎,因为它们允许用户或用户组获得执行文件时的特权。因此,应该仅在需要时才设置这些权限,并确保只有可信的用户或用户组可以访问相应的文件。
除了之前都懂的一些关于Linux的基本内容,再补充一些概念:
在Linux中,用户可以具有三种不同的类型,分别是r、e和s。
r类型表示的是“real”用户,即真实存在的用户。这些用户可以是系统管理员、普通用户等。他们通常在系统上创建和管理文件,并可以授权其他用户对文件进行访问。
e类型表示的是“effective”用户,即实际执行操作的用户。在Linux中,每个进程都有一个有效的用户ID(EUID),该ID标识了该进程实际执行操作的用户。例如,如果一个进程是由root用户创建的,但是后来被普通用户获取了控制权,那么该进程的有效用户ID将变为普通用户的ID。
s类型表示的是“saved”用户,即保存了进程在父进程中的用户ID。在Linux中,每个进程都有一个保存的用户ID(SUID),该ID标识了该进程原本的用户。当进程执行一些需要特权操作时,该进程的有效用户ID将变为保存的用户ID,以模拟在父进程中的权限。当进程执行完特权操作后,有效用户ID将恢复为实际用户ID。
综上所述,Linux中的用户类型包括r、e和s,它们分别表示真实用户、实际执行操作的用户和保存的用户ID。这些类型有助于确保Linux系统的安全性和稳定性。
针对这块补充一些函数:
getuid()、geteuid()、getgid()、getegid()、setuid()、setgid()、seteuid()、setegid()、setreuid()、setregid();
最后两个函数是用来交换 real id 和 effective id 的:
注意这最后两个函数的交换操作是原子的。
一个小例子:
我们来写一个可以大概实现 uid sudo cat /etc/shadow :获得 用户id为 uid 的用户的权限然后去访问受保护权限较高的shadow文件这样的效果。
这个程序注意要切换到root用来才能跑起来嗷。
system()函数
在Linux中,system()函数是一个由POSIX标准定义的系统调用。它用于在宿主操作系统上执行一个shell命令。system()函数的原型如下:
int system(const char *command);
其中,command参数是一个字符串,表示要执行的shell命令。函数返回值是一个整数,表示命令的执行结果。
system()函数会创建一个子进程来执行指定的shell命令。命令的执行过程与在终端中直接输入该命令的效果相同。如果命令成功执行,返回值通常为0,表示成功;如果命令执行失败,返回值通常为-1,表示错误。
需要注意的是,system()函数的使用存在一些安全风险。因为它会执行参数中的命令,如果这个命令来自不可信的源,可能会造成系统安全问题。因此,在使用system()函数时,应该谨慎处理传递给它的命令。
此外,system()函数的实现可能因操作系统而异。在某些情况下,它可能不是可移植的或者可能无法正常工作。如果需要在Linux中执行shell命令,建议使用更安全和可移植的方法,例如使用fork()和exec()系列函数来创建子进程并执行命令。
我们来写一个简单的例子:
所以system这个函数其实可以理解为 fork + exec + wait 的封装。
进程时间
主要涉及一个函数:times():
守护进程
在Linux中,会话(Session)是指用户与操作系统交互的一段时间。一个会话可以由一个或多个进程组成,这些进程在一个控制终端(Terminal)上进行交互。会话可以是有目的的交互,例如运行程序、打开文件等,也可以是用户无目的的浏览。
会话的概念基于终端,可以是物理终端、虚拟终端(例如TTY)或远程连接(如SSH)。在Linux中,会话可以是前台进程组或后台进程组,前台进程组中的进程可以和控制终端进行交互,而后台进程组中的进程则不能。
另外前台进程组有且只能有一个,也可以没有,后台进程组则无所谓。
会话在Linux系统中扮演着重要的角色,用户可以通过会话来启动、管理和终止进程,以及进行文件操作等。会话的概念可以帮助用户更好地理解和管理他们的操作。
而一次shell的成功登录,就可以模拟成一个会话的实现。
会话session本身也有一个标:sid 即会话id;
这回涉及到一些函数,第一个就是 setsid():
使用 ps -axj 命令可以查看完整的当前系统进程状态:
理解了 sid 会话标识号以及 setid 系统调用之后我们就不难发现守护进程的特点,其中只要是满足 PPID 为1,然后 PID 和 PGID 和 SID 相同且TTY终端为?未知的话就是守护进程啦:
比如上图这个就是个守护进程。
然后是 getpgrp()、getpgid()、setpgid():
getpgrp系统调用是用于获取调用进程所属的进程组ID(PGID),即获取当前进程的进程组ID。
getpgid系统调用是用于获取指定进程的进程组ID。
setpgid 设置一个进程组的进程组 ID。
写一个守护进程的小例子
可以看到执行之后,我们在当前进程树查看到了我们的守护进程。
查看文件也可以看到一直在写。
系统日志
首先应该明确,每一个应用程序都非常有必要写一个系统日志文件,但是这个文件肯定不是谁都可以写的。
我们可以查看现在的系统日志:
可以看到这图里的全是系统的日志文件。
那么我们怎么提交自己应用的系统日志呢?
使用 syslogd 这个服务来进行统一提交,它会将我们的日志信息写入系统:
想要使用这个服务需要下面的几个函数:
在Unix和类Unix系统(如Linux)中,系统日志是一种重要的工具,用于记录系统和应用程序的运行事件。它可以帮助管理员诊断问题、追踪攻击者,并了解系统的活动情况。这里,我们将详细解释三个与系统日志相关的函数:openlog、syslog和closelog。
openlog函数
openlog()函数用于打开系统日志,并设置后续日志记录的相关参数。
函数原型:void openlog(const char *ident, int option, int facility);
参数说明:
ident:这是一个字符串,将被添加到每个日志消息的开头。它通常是生成日志的程序的名称。
option:这是一个标志位,用于控制打开日志的方式。可选的标志包括:
----->LOG_PID:在每条日志消息中包含进程ID。
----->LOG_CONS:如果日志消息不能发送到系统日志,则直接输出到系统控制台。
----->LOG_NDELAY:立即打开连接到系统日志,而不是等到第一条消息被发送时才打开。
----->LOG_ODELAY:延迟打开连接到系统日志,直到第一条消息被发送时才打开。这是默认行为。
----->LOG_NOWAIT:不等待子进程。这个选项在某些系统中可能会有不同的行为。
facility:用于指定发送日志的程序类型。这决定了日志消息的优先级。一些常见的设施值包括LOG_USER(默认值,用于一般的用户程序)和LOG_MAIL(用于邮件系统)。
syslog函数
syslog()函数用于向系统日志发送消息。
函数原型:void syslog(int priority, const char *message, ...);
参数说明:
priority:这是日志消息的优先级。它由一个设施值和一个严重级别值组成。严重级别值可以是LOG_EMERG、LOG_ALERT、LOG_CRIT、LOG_ERR、LOG_WARNING、LOG_NOTICE、LOG_INFO或LOG_DEBUG。设施值和严重级别值的组合定义了消息的优先级。例如,LOG_USER | LOG_INFO表示来自用户的INFO级别消息。
message:这是实际的日志消息字符串。它可以是格式化的字符串,包含后续参数。
…:这是可选的后续参数,用于格式化字符串中的变量部分(如果有的话)。
closelog函数
closelog()函数用于关闭系统日志。在完成所有的日志记录操作后,应该调用此函数以释放资源。
函数原型:void closelog(void);
这个函数没有参数,直接调用即可。通常,在程序结束时调用它是最安全的做法。不过,如果你在程序中多次打开和关闭日志,也应该在每次调用openlog()后调用closelog(),以确保及时释放资源。
继续修改我们之前的守护程序
使用命令 tail /var/log/syslog 查看系统日志,可以看到我们所打印的系统日志:
这里要注意一个问题,实际上我们的程序还是有缺陷,因为守护进程其实只应该有一个,也就是说守护进程应该是单实例的(想想我们的shell终端守护进程,总不能同时存在两个终端吧,咱也只能用一个啊),但现在我们的知识储备还做不了,这会使用到锁文件的机制,锁文件存放在 /var/run/name.pid中,后面会再细说。
另外我们还需要一个守护进程的开机自启动脚本文件,将该脚本文件放在 /etc/rc.d/rc.local 内,其开机后就会自动启动,这个后面再细说。