进程

以前的计算机一次只能执行一个程序,后来有了多道程序设计的电脑,可以宏观上执行多个程序。由此产生了进程的概念

1、进程概念

进程就是一个运行中的程序,是动态的,而不是静态的,可以理解为程序是躺在磁盘中的而进程是在内存中的。一个程序的执行就是一个进程。进程是执行的程序。进程包含程序代码(文本段),程序计数器(PC),进程堆栈,和处理器寄存器内容等等,还有可能包括堆。

2、进程状态

进程在执行时是有不同的状态的,堆随时切换,进程共有5中状态,分别如下:
①新建:进程开始创建状态
②运行:该进程的指令代码正在执行
③等待:进程正在等待某个时间,比如I/O完成或收到信号
④就绪:进程等待分配处理器,万事俱备,只欠CPU
⑤终止:进程已经执行完成

这里进程从运行态可以到就绪态是因为,系统的时钟晶振每隔一段时间就会发出一个时间中断,发出中断会触发中断服务程序,此时就会交给内核来执行控制,否则进程是不会主动让出CPU的。所以原理才是这样。运行态到就绪态也叫CPU调度。
进程状态图切换图如下:

3、进程控制块(PCB)

进程控制块是每一个进程都有的,包含许多信息。上面进程状态的新建状态就是在创建进程控制块。一个进程控制块就是C语言中的一个struct结构体,包含的内容有:进程状态、程序计数器,CPU寄存器,CPU调度信息,内存管理信息,记账信息(CPU使用时间,实际使用时间等等),I/O状态信息等等,

4、进程调度

我们知道现在的计算机都可以看似同时运行好多个程序,其实每一个CPU核只能同时运行一个进程,只不过宏观上看起来是同时运行,单核CPU还是一个一个运行的。为了进一步的最大化CPU利用率,我们引入进程调度概念。
在不同的操作系统中,进程控制块的组织结构不同,一种是各个PCB组成一个数组,然后处于运行状态的进程用一个指针数组来指向,为运行队列,同样有就绪队列,用一个指针数组指向。调度到哪个就把哪个取出来。

调度的进程程序,分为长期调度程序(执行不频繁)和CPU调度程序(经常使用CPU)
最初,新创建的进程被加到就绪队列,他就在就绪队列中等待,直到被选中,选中后,进程可能发出I/O请求,放到I/O队列,可能创建一个子进程,等待其终止。可能被中断放到就绪队列。

要知道进程之间的切换是要有额外的开销的,切换的时候要保存各种信息,切换CPU到另一个进程需要保存当前进程状态和恢复另一个进程的状态,成为上下文切换,比较浪费时间。现在有的处理器为了节约这段时间,直接设置了在不同状态下所需要的不同的寄存器,只需要切换状态即可,比如ARM。

进程调度的算法有很多种:
先到先服务调度(FCFS)
最短作业优先调度(SJF)
多级队列调度
多级反馈队列调度,现代的计算机操作系统基本上都是这种方法来进行进程调度。

5、进程的创建

新进程的创建,首先在内存中为新进程创建一个task_struct结构,然后将父进程的task_struct内容复制其中,再修改部分数据。分配新的内核堆栈、新的PID、再将task_struct 这个node添加到链表中。所谓创建,实际上是“复制”。
子进程刚开始,内核并没有为它分配物理内存,而是以只读的方式共享父进程内存,只有当子进程写时,才复制。即“copy-on-write”(写时复制)。

#include <unistd.h> 
pid_t fork(void)

fork函数时调用一次,返回两次。在父进程和子进程中各调用一次。子进程中返回值为0,父进程中返回值为子进程的PID。程序员可以根据返回值的不同让父进程和子进程执行不同的代码。

#include <stdio.h> 
#include <sys/types.h> 
#include <unistd.h> 

int main(void)
{
    pid_t p1 = -1;
    
    p1 = fork();        // 返回2次     
    if (p1 == 0)
    {
        // 这里一定是子进程         
        // 先sleep一下让父进程先运行,先死         
        sleep(1);
        
        printf("子进程, pid = %d.\n", getpid());        
        printf("hello world.\n");
        printf("子进程, 父进程ID = %d.\n", getppid());
    }
    
    if (p1 > 0)
    {
        // 这里一定是父进程         
        printf("父进程, pid = %d.\n", getpid());
        printf("父进程, p1 = %d.\n", p1);
    }
    
    if (p1 < 0)
    {
        // 这里一定是fork出错了         
    }
    
    // 在这里所做的操作     
    //printf("hello world, pid = %d.\n", getpid()); 
    return 0;
}

子进程和父进程之间并没有对各自的变量产生影响。
一般来说,fork之后父、子进程执行顺序是不确定的,这取决于内核调度算法。进程之间实现同步需要进行进程通信。
vfork函数
vfork与fork对比:
相同点:返回值相同
不同点:fork创建子进程,把父进程数据空间、堆和栈复制一份;vfork创建子进程,与父进程内存数据共享;

6、进程终止

正常终止(5种)\
①从main返回,等效于调用exit
②调用exit:exit 首先调用各终止处理程序,然后按需多次调用fclose,关闭所有的打开流。
③调用_exit或者_Exit
④最后一个线程从其启动例程返回
⑤最后一线程调用pthread_exit
异常终止(3种)
①调用abort
②接到一个信号并终止
③最后一个线程对取消请求作出响应
僵尸进程
(1)子进程先于父进程结束。子进程结束后父进程此时并不一定立即就能帮子进程“收尸”,在这一段(子进程已经结束且父进程尚未帮其收尸)子进程就被成为僵尸进程。
(2)子进程除task_struct和栈外其余内存空间皆已清理
(3)父进程可以使用wait或waitpid以显式回收子进程的剩余待回收内存资源并且获取子进程退出状态。
(4)父进程也可以不使用wait或者waitpid回收子进程,此时父进程结束时一样会回收子进程的剩余待回收内存资源。(这样设计是为了防止父进程忘记显式调用wait/waitpid来回收子进程从而造成内存泄漏)
孤儿进程
(1)父进程先于子进程结束,子进程成为一个孤儿进程。
(2)linux系统规定:所有的孤儿进程都自动成为一个特殊进程(进程1,也就是init进程)的子进程。
如何查看僵尸进程?
linux中命令ps,标记为Z的进程就是僵尸进程。

7、wait和waitpid函数

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *wstatus);
pid_t waitpid(pid_t pid, int *wstatus, int options);

wait用于使父进程阻塞,等待子进程退出;waitpid有若干选项,如可以提供一个非阻塞版本的wait,也能实现和wait相同的功能,实际上,linux中wait的实现也是通过调用waitpid实现的。

waitpid返回值:正常返回子进程号;使用WNOHANG且没有子进程退出返回0;调用出错返回-1;

wait的工作原理

(1)子进程结束时,系统向其父进程发送SIGCHILD信号

(2)父进程调用wait函数后阻塞

(3)父进程被SIGCHILD信号唤醒然后去回收僵尸子进程

(4)父子进程之间是异步的,SIGCHILD信号机制就是为了解决父子进程之间的异步通信问题,让父进程可以及时的去回收僵尸子进程。

(5)若父进程没有任何子进程则wait返回错误

wait的参数status。status用来返回子进程结束时的状态,父进程通过wait得到status后就可以知道子进程的一些结束状态信息。
wait的返回值pid_t,这个返回值就是本次wait回收的子进程的PID。当前进程有可能有多个子进程,wait函数阻塞直到其中一个子进程结束wait就会返回,wait的返回值就可以用来判断到底是哪一个子进程本次被回收了。 对wait做个总结:wait主要是用来回收子进程资源,回收同时还可以得知被回收子进程的pid和退出状态。

status宏功能
WIFEXITED用来判断子进程是否正常终止(return、exit、_exit退出)
WIFSIGNALED用来判断子进程是否非正常终止(被信号所终止)
WEXITSTATUS用来得到正常终止情况下的进程返回值的。
option宏功能
WNOHANG如果pid指定的子进程没有结束,则waitpid()函数立即返回0,而不是阻塞在这个函数上等待;如果结束了,则返回该子进程的进程号
WUNTRACED如果子进程进入暂停状态,则马上返回。

#include <stdio.h> 
#include <unistd.h> 
#include <sys/types.h>  
#include <sys/wait.h> 
#include <stdlib.h> 


int main(void)
{
    pid_t pid = -1;
    pid_t ret = -1;
    int status = -1;
    
    pid = fork();
    if (pid > 0)
    {
        // 父进程         
        //sleep(1);         
        printf("parent.\n");
        ret = wait(&status);
        
        printf("子进程已经被回收,子进程pid = %d.\n", ret);
        printf("子进程是否正常退出:%d\n", WIFEXITED(status));
        printf("子进程是否非正常退出:%d\n", WIFSIGNALED(status));
        printf("正常终止的终止值是:%d.\n", WEXITSTATUS(status));
    }
    else if (pid == 0)
    {
        // 子进程         printf("child pid = %d.\n", getpid());
        return 51;
        //exit(0);     }
    else
    {
        perror("fork");
        return -1;
    }
    
    return 0;
}

其中WIFEXITED、WIFSIGNALED、WEXITSTATUS这几个是宏定义可以man wait 就知道了

ps -ef|grep defunc可以找出僵尸进程

ps -l 可以得到更详细的进程信息

运行两次之后发现有两个Z进程,然后等待一分钟后,Z进程被父进程回收。

O:进程正在处理器运行\
S:休眠状态
R:等待运行
I:空闲状态
Z:僵尸状态
T:跟踪状态
B:进程正在等待更多的内存分页
C:cpu利用率的估算值
收集僵尸进程的信息,并终结这些僵尸进程,需要我们在父进程中使用waitpid和wait,这两个函数能够手机僵尸进程留下的信息并使进程彻底消失。

8、execve

exec族函数

1、为什么需要exec函数

(1)fork子进程是为了执行新程序(fork创建了子进程后,子进程和父进程同时被OS调度执行,因此子进程可以单独的执行一个程序,这个程序宏观上将会和父进程程序同时进行)

(2)可以直接在子进程的if中写入新程序的代码。这样可以,但是不够灵活,因为我们只能把子进程程序的源代码贴过来执行(必须知道源代码,而且源代码太长了也不好控制),譬如说我们希望子进程来执行ls -la 命令就不行了(没有源代码,只有编译好的可执行程序)

(3)使用exec族运行新的可执行程序(exec族函数可以直接把一个编译好的可执行程序直接加载运行)

(4)我们有了exec族函数后,我们典型的父子进程程序是这样的:子进程需要运行的程序被单独编写、单独编译连接成一个可执行程序(叫hello),(项目是一个多进程项目)主程序为父进程,fork创建了子进程后在子进程中exec来执行hello,达到父子进程分别做不同程序同时(宏观上)运行的效果。

2、exec族的6个函数介绍

(1)execl和execv 这两个函数是最基本的exec,都可以用来执行一个程序,区别是传参的格式不同。execl是把参数列表(本质上是多个字符串,必须以NULL结尾)依次排列而成(l其实就是list的缩写),execv是把参数列表事先放入一个字符串数组中,再把这个字符串数组传给execv函数。

(2)execlp和execvp 这两个函数在上面2个基础上加了p,较上面2个来说,区别是:上面2个执行程序时必须指定可执行程序的全路径(如果exec没有找到path这个文件则直接报错),而加了p的传递的可以是file(也可以是path,只不过兼容了file。加了p的这两个函数会首先去找file,如果找到则执行执行,如果没找到则会去环境变量PATH所指定的目录下去找,如果找到则执行如果没找到则报错)

(3)execle和execvpe 这两个函数较基本exec来说加了e,函数的参数列表中也多了一个字符串数组envp形参,e就是environment环境变量的意思,和基本版本的exec的区别就是:执行可执行程序时会多传一个环境变量的字符串数组给待执行的程序。

execlp和execvp

(1)加p和不加p的区别是:不加p时需要全部路径+文件名,如果找不到就报错了。加了p之后会多帮我们到PATH所指定的路径下去找一下。

execle和execvpe

(1)main函数的原型其实不止是

int main(int argc, char argv[]),而可以是int main(int argc, char argv[], char **env)

第三个参数是一个字符串数组,内容是环境变量。

(2)如果用户在执行这个程序时没有传递第三个参数,则程序会自动从父进程继承一份环境变量(默认的,最早来源于OS中的环境变量);如果我们exec的时候使用execlp或者execvpe去给传一个envp数组,则程序中的实际环境变量是我们传递的这一份(取代了默认的从父进程继承来的那一份)

可执行文件装入内核的linux_binprm结构体。

进程调用exec时,该进程执行的程序完全被替换,新的程序从main函数开始执行。因为调用exec并不创建新进程,只是替换了当前进程的代码区、数据区、堆和栈。

当指定filename作为参数时:

如果filename中包含/,则将其视为路径名。

否则,就按系统的PATH环境变量,在它所指定的各个目录中搜索可执行文件。

*出于安全方面的考虑,有些人要求在搜索路径中不要包括当前目录。

execl中的参数列表第二个参数要写上程序的名字,不然会产生莫名其妙的错误。


#include <stdio.h> 
#include <unistd.h> 
#include <sys/types.h>  
#include <sys/wait.h> 
#include <stdlib.h> 


int main(void)
{
    pid_t pid = -1;
    pid_t ret = -1;
    int status = -1;
    
    pid = fork();
    if (pid > 0)
    {
        // 父进程         
        printf("parent, 子进程id = %d.\n", pid);
    }
    else if (pid == 0)
    {
        // 子进程         
        //execl("/bin/ls", "ls", "-l", "-a", NULL);    // ls -l -a         
        //char * const arg[] = {"ls", "-l", "-a", NULL};         
        //execv("/bin/ls", arg);         
        //execl("hello", "aaa", "bbb", NULL);         
        //char * const arg[] = {"aaa", "bbb", NULL};         
        //execv("hello", arg);         
        //execlp("ls", "ls", "-l", "-a", NULL);             
        char * const envp[] = {"AA=aaaa", "XX=abcd", NULL};
        execle("hello", "hello", "-l", "-a", NULL, envp);
        
        return 0;
    }
    else
    {
        perror("fork");
        return -1;
    }
    
    return 0;
}

9、system函数

system函数简介
(1)system函数 = fork+exec
原子操作。原子操作意思就是整个操作一旦开始就会不被打断的执行完。原子操作的好处就是不会被人打断(不会引来竞争状态),坏处是自己单独连续占用CPU时间太长影响系统整体实时性,因此应该尽量避免不必要的原子操作,就算不得不原子操作也应该尽量原子操作的时间缩短。
进程关系
(1)无关系
(2)父子进程关系
(3)进程组(group)由若干进程构成一个进程组
(4)会话(session)会话就是进程组的组

10、守护进程Daemon

是linux的后台服务进程。它是一个生存周期较长的进程,没有控制终端,输出无处显示。用户层守护进程的父进程是init进程。

我们自己手写建立一个create_daemon函数,来实现守护进程的功能。(不是API)
顺序
(1)子进程等待父进程退出
(2)子进程使用setsid创建新的会话期,脱离控制台
(3)调用chdir将当前工作目录设置为/
(4)umask设置为0以取消任何文件权限屏蔽
(5)关闭所有文件描述符
(6)将0、1、2定位到/dev/null

#include <stdio.h> 
#include <unistd.h> 
#include <stdlib.h> 
#include <sys/types.h> 
#include <sys/stat.h> 
#include <fcntl.h> 

void create_daemon(void);


int main(void)
{
    create_daemon();
    
    while (1)
    {
        printf("I am running.\n");
        sleep(1);
    }
    
    return 0;
}


// 函数作用就是把调用该函数的进程变成一个守护进程 
void create_daemon(void)
{
    pid_t pid = 0;
    
    pid = fork();
    if (pid < 0)
    {
        perror("fork");
        exit(-1);
    }
    if (pid > 0)
    {
        exit(0);        // 父进程直接退出     
        }
    
    // 执行到这里就是子进程     
    // setsid将当前进程设置为一个新的会话期session,目的就是让当前进程     
    // 脱离控制台。     
    pid = setsid();
    if (pid < 0)
    {
        perror("setsid");
        exit(-1);
        }
    
    // 将当前进程工作目录设置为根目录     
    chdir("/");
    
    // umask设置为0确保将来进程有最大的文件操作权限     
    umask(0);
    
    // 关闭所有文件描述符     
    // 先要获取当前系统中所允许打开的最大文件描述符数目     
    int cnt = sysconf(_SC_OPEN_MAX);
    int i = 0;
    for (i=0; i<cnt; i++)
    {
        close(i);
    }
    
    open("/dev/null", O_RDWR);
    open("/dev/null", O_RDWR);
    open("/dev/null", O_RDWR);

}

真正编写调试的时候会发现需要杀死守护进程。
如何杀死守护进程?
ps -aux 找到对应PID
kill -9 PID

11、syslog日志系统

函数介绍
对于linux系统中提供的日志打印服务,提供了一些库函数,一般使用的就是三个,openlog()、syslog()、closelog()

       #include <syslog.h>
 
       void openlog(const char *ident, int option, int facility);
       void syslog(int priority, const char *format, ...);
       void closelog(void);
       void openlog(const char *ident, int option, int facility);  用于打开系统记录

ident   指向的字符串可以是想要打出的任意字符,它所表示的字符串将固定地加在每行日志的前面以标识这个日志,该标志通常设置为程序的名称。
option  参数所指定的标志用来控制openlog()操作和syslog()的后续调用。他的值为具体是下列值取或运算的结果

option  参数功能
LOG_CONS直接写入系统控制台,如果有一个错误,同时发送到系统日志记录。
LOG_NDELAY立即打开连接(通常,打开连接时记录的第一条消息)。
LOG_NOWAIT不要等待子进程,因为其有可能在记录消息的时候就被创建了(GNU C库不创建子进程,所以该选项在Linux上没有影响。)
LOG_ODELAY延迟连接的打开直到syslog函数调用。(这是默认情况下,需要没被指定的情况下。)
LOG_PERROR(不在SUSv3情况下)同时输出到stderr(标准错误文件)。
LOG_PID包括每个消息的PID。

facility参数是用来指定记录消息程序的类型。它让指定的配置文件,将以不同的方式来处理来自不同方式的消息。它的值可能为 LOG_KERN、LOG_USER、LOG_MAIL、LOG_DAEMON、LOG_AUTH、LOG_SYSLOG、LOG_LPR、LOG_NEWS、LOG_UUCP、LOG_CRON 或 LOG_AUTHPRIV。具体每一个值的含义可以查看man手册
但是对于一般的使用者都会使用默认的 LOG_USER

facility功能
LOG_AUTHsecurity/authorization messages
LOG_AUTHPRIVsecurity/authorization messages (private)
LOG_CRONclock daemon (cron and at)
LOG_DAEMONsystem daemons without separate facility value
LOG_FTPftp daemon
LOG_KERNkernel messages (these can't be generated from user processes)
LOG_LOCAL0through LOG_LOCAL7 reserved for local use
LOG_LPRline printer subsystem
LOG_MAILmail subsystem
LOG_NEWSUSENET news subsystem
LOG_SYSLOGmessages generated internally by syslogd(8)
LOG_USER (default)generic user-level messages
LOG_UUCPUUCP subsystem

void syslog(int priority, const char *format, ...);   记录至系统记录

priority  指的是调试信息的优先级,跟内核中的printk函数类似。内核中定义的各种级别如下:

priority功能
LOG_EMERG紧急情况,需要立即通知技术人员。
LOG_ALERT应该被立即改正的问题,如系统数据库被破坏,ISP连接丢失。
LOG_CRIT重要情况,如硬盘错误,备用连接丢失。
LOG_ERR错误,不是非常紧急,在一定时间内修复即可。
LOG_WARNING警告信息,不是错误,比如系统磁盘使用了85%等。
LOG_NOTICE不是错误情况,也不需要立即处理。
LOG_INFO情报信息,正常的系统消息,比如骚扰报告,带宽数据等,不需要处理。
LOG_DEBUG包含详细的开发情报的信息,通常只在调试一个程序时使用。

void closelog(void);   关闭日志文件,这个函数就没有什么好说的啦,没有任何参数,直接关闭日志文件。

#include <stdio.h>
#include <syslog.h>
#include <sys/types.h>
#include <unistd.h>
 
int main(void)
{
    printf("my pid = %d.\n", getpid());
        
    /*在日志中虽然写了文件名,但是出于准确考虑,也会显示该进程的pid,因为pid才是一个进程的唯一标识*/
    openlog("a.out", LOG_PID | LOG_CONS, LOG_USER);    
        
    syslog(LOG_INFO, "this is my log info.%d", 23);        //使用方法与printk 类似可以格式化的输出
        
    syslog(LOG_INFO, "this is another log info.");
    syslog(LOG_INFO, "this is 3th log info.");
    
    closelog();
}

在Ubuntu中日志在/var/log/syslog文件中,不同linux的发行版路径会不一样,其他的日志可能会在/var/log/messages

 

syslog的工作原理
(1)操作系统中有一个守护进程syslogd(开机运行,关机时才结束),这个守护进程syslogd负责进行日志文件的写入和维护。

(2)syslogd是独立于我们任意一个进程而运行的。我们当前进程和syslogd进程本来是没有任何关系的,但是我们当前进程可以通过调用openlog打开一个和syslogd相连接的通道,然后通过syslog向syslogd发消息,然后由syslogd来将其写入到日志文件系统中。

(3)syslogd其实就是一个日志文件系统的服务器进程,提供日志服务。任何需要写日志的进程都可以通过openlog/syslog/closelog这三个函数来利用syslogd提供的日志服务。这就是操作系统的服务式的设计。

12、让程序不能被多次运行

1、问题

(1)因为守护进程是长时间运行而不退出,因此./a.out执行一次就有一个进程,执行多次就有多个进程。

(2)这样并不是我们想要的。我们守护进程一般都是服务器,服务器程序只要运行一个就够了,多次同时运行并没有意义甚至会带来错误。

(3)因此我们希望我们的程序具有一个单例运行的功能。意思就是说当我们./a.out去运行程序时,如果当前还没有这个程序的进程运行则运行之,如果之前已经有一个这个程序的进程在运行则本次运行直接退出(提示程序已经在运行)。

2、实现方法:

(1)最常用的一种方法就是:用一个文件的存在与否来做标志。具体做法是程序在执行之初去判断一个特定的文件是否存在,若存在则标明进程已经在运行,若不存在则标明进程没有在运行。然后运行程序时去创建这个文件。当程序结束的时候去删除这个文件即可。

(2)这个特定文件要古怪一点,确保不会凑巧真的在电脑中存在的。

13、 linux的进程间通信(IPC)概述

进程间通信方式很多:
信号
管道
mmap
共享内存
消息队列
信号量集
网络

1、管道

无名管道
(1)管道通信的原理:内核维护的一块内存,有读端和写端(管道是单向通信的)
(2)管道通信的方法:父进程创建管理后fork子进程,子进程继承父进程的管道fd
(3)管道通信的限制:只能在父子进程间通信、半双工
(4)管道通信的函数:pipe、write、read、close
有名管道(fifo)
(1)有名管道的原理:实质也是内核维护的一块内存,表现形式为一个有名字的文件
(2)有名管道的使用方法:固定一个文件名,2个进程分别使用mkfifo创建fifo文件,然后分别open打开获取到fd,然后一个读一个写
(3)管道通信限制:半双工(注意不限父子进程,任意2个进程都可)
(4)管道通信的函数:mkfifo、open、write、read、close

2、消息队列

(1)本质上是一个队列,队列可以理解为(内核维护的一个)FIFO
(2)工作时A和B2个进程进行通信,A向队列中放入消息,B从队列中读出消息。

3、信号量

(1)实质就是个计数器(其实就是一个可以用来计数的变量,可以理解为int a
(2)通过计数值来提供互斥和同步

4、共享内存

(1)大片内存直接映射
(2)类似于LCD显示时的显存用法

5、剩余的2类IPC

(1)信号
(2)Unix域套接字 socket

Last modification:October 8th, 2019 at 12:19 am
如果觉得我的文章对你有用,请随意赞赏

Leave a Comment