46、进程与线程学习指南

进程与线程学习指南及通信方式

进程与线程学习指南

1. 进程终止

进程的终止可以是主动的,也可以是被动的。主动终止可通过调用 exit(3) 函数实现;被动终止则是在接收到未处理的信号时发生,其中 SIGKILL 信号无法被处理,会直接终止进程。

无论以何种方式终止进程,都会停止所有线程,关闭所有文件描述符,并释放所有内存。系统会向父进程发送 SIGCHLD 信号,告知子进程已终止。

进程有一个返回值,若进程正常终止,返回值为 exit 函数的参数;若进程被信号终止,返回值为信号编号。在 shell 脚本中,可通过返回值判断程序执行是否成功,通常返回值为 0 表示成功,其他值表示失败。

父进程可使用 wait(2) waitpid(2) 函数收集子进程的返回值。但在子进程终止到父进程收集返回值之间会有延迟,在此期间,返回值需存储,且已终止进程的 PID 不能被重用,处于这种状态的进程被称为僵尸进程,在 ps top 命令中显示为状态 Z 。只要父进程在收到 SIGCHLD 信号通知子进程终止时调用 wait waitpid 函数,僵尸进程通常存在时间极短,不会在进程列表中显示。若父进程未能收集返回值,最终会因资源不足而无法创建新进程。

以下是一个示例程序,展示了进程的创建和终止:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
    int pid;
    int status;
    pid = fork();
    if (pid == 0) {
        printf("I am the child, PID %d\n", getpid());
        sleep(10);
        exit(42);
    } else if (pid > 0) {
        printf("I am the parent, PID %d\n", getpid());
        wait(&status);
        printf("Child terminated, status %d\n", WEXITSTATUS(status));
    } else
        perror("fork:");
    return 0;
}

运行该程序,输出如下:

I am the parent, PID 13851
I am the child, PID 13852
Child terminated with status 42

子进程会继承父进程的大部分属性,包括用户和组 ID、所有打开的文件描述符、信号处理和调度特性。

2. 运行不同程序

fork 函数可创建一个正在运行程序的副本,但不能运行不同的程序。若要运行不同程序,需使用 exec 系列函数:

int execl(const char *path, const char *arg, ...); 
int execlp(const char *file, const char *arg, ...); 
int execle(const char *path, const char *arg, ..., char * const envp[]); 
int execv(const char *path, char *const argv[]); 
int execvp(const char *file, char *const argv[]); 
int execvpe(const char *file, char *const argv[], ..., char *const envp[]);

这些函数接受要加载和运行的程序文件的路径。若函数调用成功,内核会丢弃当前进程的所有资源,包括内存和文件描述符,并为新程序分配内存。调用 exec* 函数的线程返回时,不会返回到调用后的代码行,而是返回到新程序的 main() 函数。

以下是一个命令启动器的示例程序:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(int argc, char *argv[])
{
    char command_str[128];
    int pid;
    int child_status;
    int wait_for = 1;
    while (1) {
        printf("sh> ");
        scanf("%s", command_str);
        pid = fork();
        if (pid == 0) {
            /* child */
            printf("cmd '%s'\n", command_str);
            execl(command_str, command_str, (char *)NULL);
            /* We should not return from execl, so only get to this line if it failed */
            perror("exec");
            exit(1);
        }
        if (wait_for) {
            waitpid(pid, &child_status, 0);
            printf("Done, status %d\n", child_status);
        }
    }
    return 0;
}

运行该程序,输入 /bin/ls 命令,输出如下:

# ./exec-demo
sh> /bin/ls
cmd '/bin/ls'
bin etc lost+found proc sys var
boot home media run tmp
dev lib mnt sbin usr
Done, status 0
sh>

可通过输入 Ctrl + C 终止程序。

虽然 fork 函数复制现有进程, exec 函数丢弃当前进程资源并加载新程序,且通常 fork 后会立即调用 exec 函数,但这样做有明显优势,例如便于在 shell 中实现重定向和管道。

以获取目录列表为例,正常情况下的操作流程如下:
1. 在 shell 提示符下输入 ls
2. shell 复制自身。
3. 子进程执行 /bin/ls
4. ls 程序将目录列表输出到标准输出(文件描述符 1),连接到终端,你将看到目录列表。
5. ls 程序终止,shell 重新获得控制权。

若要将目录列表重定向到文件,操作流程如下:
1. 在 shell 提示符下输入 ls > listing.txt
2. shell 复制自身。
3. 子进程打开并截断 listing.txt 文件,使用 dup2(2) 函数将文件的文件描述符复制到标准输出(文件描述符 1)。
4. 子进程执行 /bin/ls
5. ls 程序像之前一样输出列表,但这次写入 listing.txt 文件。
6. ls 程序终止,shell 重新获得控制权。

3. 守护进程

守护进程是在后台运行的进程,由 init 进程管理,且不连接到控制终端。创建守护进程的步骤如下:
1. 调用 fork 创建新进程,父进程退出,使子进程成为孤儿进程,由 init 进程接管。
2. 子进程调用 setsid(2) 函数,创建新的会话和进程组,使其成为唯一成员,从而与控制终端隔离。
3. 将工作目录更改为根目录。
4. 关闭所有文件描述符,并将标准输入、标准输出和标准错误(文件描述符 0、1 和 2)重定向到 /dev/null ,以隐藏所有输入和输出。

幸运的是,以上所有步骤可通过调用 daemon(3) 函数一次性完成。

4. 进程间通信

每个进程都是独立的内存区域,进程间传递信息有两种方式:一是将信息从一个地址空间复制到另一个地址空间;二是创建一个多个进程都可访问的共享内存区域。

第一种方式通常结合队列或缓冲区,使进程间按顺序传递消息,这意味着消息需复制两次:先复制到临时区域,再复制到目标地址。例如,套接字、管道和消息队列都采用这种方式。

第二种方式不仅需要创建可映射到多个地址空间的内存区域,还需要同步对该内存的访问,例如使用信号量或互斥锁。

POSIX 提供了实现上述功能的函数。此外,还有较旧的 System V IPC API,提供消息队列、共享内存和信号量,但不如 POSIX 函数灵活。

消息传递协议通常比共享内存更易于编程和调试,但在消息较大或数量较多时速度较慢。

5. 基于消息的进程间通信

基于消息的进程间通信有多种选择,区分它们的属性如下:
- 消息流是单向还是双向。
- 数据流是无消息边界的字节流,还是保留消息边界的离散消息。若是后者,消息的最大大小很重要。
- 消息是否带有优先级标签。

以下是 FIFOs、套接字和消息队列的属性总结表格:
| 类型 | 消息流方向 | 数据流类型 | 消息优先级 |
| ---- | ---- | ---- | ---- |
| Unix 套接字 | 双向 | 字节流或离散消息 | 无 |
| FIFOs 和命名管道 | 单向 | 字节流 | 无 |
| POSIX 消息队列 | 双向 | 离散消息 | 有 |

5.1 Unix 套接字

Unix 套接字(或本地套接字)能满足大多数需求,且由于套接字 API 广为人知,是最常用的机制。

Unix 套接字使用 AF_UNIX 地址族创建,并绑定到路径名,对套接字的访问由套接字文件的访问权限决定。与互联网套接字一样,套接字类型可以是 SOCK_STREAM SOCK_DGRAM ,前者提供双向字节流,后者提供保留边界的离散消息。Unix 套接字数据报可靠,不会丢失或乱序,其最大大小取决于系统,可通过 /proc/sys/net/core/wmem_max 获取,通常为 100 KiB 或更大。Unix 套接字没有指示消息优先级的机制。

5.2 FIFOs 和命名管道

FIFOs 和命名管道是同一事物的不同叫法,是匿名管道的扩展,用于在 shell 中实现管道时在父子进程间通信。

FIFO 是一种特殊文件,可使用 mkfifo(1) 命令创建。与 Unix 套接字一样,文件访问权限决定谁可以读写。FIFO 是单向的,通常有一个读取者和一个写入者,但也可以有多个。数据是纯字节流,但保证小于管道关联缓冲区大小的消息的原子性,即小于该大小的写入不会被拆分为多个小写入,只要接收端缓冲区足够大,就可以一次性读取整个消息。现代内核中,FIFO 缓冲区的默认大小为 64 KiB,可使用 fcntl(2) 函数结合 F_SETPIPE_SZ 选项将其增大到 /proc/sys/fs/pipe-max-size 指定的值,通常为 1 MiB。FIFO 没有优先级概念。

5.3 POSIX 消息队列

消息队列由名称标识,名称必须以斜杠 / 开头,且只能包含一个 / 字符,实际上消息队列存储在 mqueue 类型的伪文件系统中。可使用 mq_open(3) 函数创建队列或获取现有队列的引用,该函数返回文件描述符。每个消息都有优先级,消息按优先级和到达顺序从队列中读取。消息最大长度为 /proc/sys/kernel/msgmax 字节,默认值为 8 KiB,可通过向 /proc/sys/kernel/msgmax 写入值将其设置为 128 字节到 1 MiB 之间的任意大小。由于引用是文件描述符,可使用 select(2) poll(2) 等函数等待队列中的活动。

基于消息的进程间通信总结:
- Unix 套接字使用最频繁,因为它能满足大多数需求,除了可能缺乏消息优先级功能,且在大多数操作系统上都有实现,具有最大的可移植性。
- FIFOs 使用较少,主要是因为缺乏类似数据报的功能,但 API 非常简单,提供了常规的文件操作函数,如 open(2) close(2) read(2) write(2)
- 消息队列使用最少,内核中的代码路径不像套接字(网络)和 FIFO(文件系统)调用那样经过优化。

此外,还有一些更高级的抽象,如 D-Bus,正从主流 Linux 应用向嵌入式设备发展,其底层使用 Unix 套接字和共享内存。

6. 基于共享内存的进程间通信

共享内存可避免在地址空间之间复制数据,但会引入同步访问的问题,进程间的同步通常使用信号量实现。

6.1 POSIX 共享内存

要在进程间共享内存,需创建新的内存区域,并将其映射到每个需要访问的进程的地址空间。

POSIX 共享内存段的命名规则与消息队列类似,名称以 / 开头,且只能包含一个 / 字符。例如:

#define SHM_SEGMENT_NAME "/demo-shm"

可使用 shm_open(3) 函数根据名称获取共享内存段的文件描述符。若共享内存段不存在且设置了 O_CREAT 标志,则会创建新的共享内存段,初始大小为 0,可使用 ftruncate(2) 函数将其扩展到所需大小:

int shm_fd;
struct shared_data *shm_p;
/* Attempt to create the shared memory segment */
shm_fd = shm_open(SHM_SEGMENT_NAME, O_CREAT | O_EXCL | O_RDWR, 0666);
if (shm_fd > 0) {
    /* succeeded: expand it to the desired size (Note: dont't do this every time because ftruncate fills it with zeros) */
    printf("Creating shared memory and setting size=%d\n", SHM_SEGMENT_SIZE);
    if (ftruncate(shm_fd, SHM_SEGMENT_SIZE) < 0) {
        perror("ftruncate");
        exit(1);
    }
    // ...
} else if (shm_fd == -1 && errno == EEXIST) {
    /* Already exists: open again without O_CREAT */
    shm_fd = shm_open(SHM_SEGMENT_NAME, O_RDWR, 0);
    // ...
}

获取共享内存的描述符后,使用 mmap(2) 函数将其映射到进程的地址空间,使不同进程的线程可以访问该内存:

/* Map the shared memory */
shm_p = mmap(NULL, SHM_SEGMENT_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);

以下是一个使用共享内存段在进程间通信的示例程序:

static sem_t *demo_sem;
// ...
int main(int argc, char *argv[])
{
    char *shm_p;
    printf("%s PID=%d\n", argv[0], getpid());
    shm_p = get_shared_memory();
    while (1) {
        printf("Press enter to see the current contents of shm\n");
        getchar();
        sem_wait(demo_sem);
        printf("%s\n", shm_p);
        /* Write our signature to the shared memory */
        sprintf(shm_p, "Hello from process %d\n", getpid());
        sem_post(demo_sem);
    }
    return 0;
}

该程序使用共享内存段在进程间传递消息,消息格式为 Hello from process 后跟进程的 PID。 get_shared_memory 函数负责创建共享内存段(若不存在)或获取其文件描述符(若已存在),并返回指向共享内存段的指针。注意,程序中使用了信号量来同步对内存的访问,避免一个进程覆盖另一个进程的消息。

要测试该程序,需在两个不同的终端会话中运行该程序的两个实例。在第一个终端中,输出如下:

# ./shared-mem-demo
./shared-mem-demo PID=271
Creating shared memory and setting size=65536
Press enter to see the current contents of shm
Press enter to see the current contents of shm
Hello from process 271

由于这是第一次运行程序,会创建共享内存段,初始时消息区域为空,经过一次循环后,会写入当前进程的 PID。然后在另一个终端中运行第二个实例,输出如下:

# ./shared-mem-demo
./shared-mem-demo PID=279
Press enter to see the current contents of shm
Hello from process 271
Press enter to see the current contents of shm
Hello from process 279

由于共享内存段已存在,第二个实例不会创建,而是显示已有的消息。按下回车键会写入当前进程的 PID,第一个程序可以看到该消息,从而实现两个进程间的通信。

POSIX IPC 函数是 POSIX 实时扩展的一部分,编译时需要链接 librt 库,且 POSIX 信号量在 POSIX 线程库中实现,还需要链接 pthreads 库。以 Arm Cortex - A8 SoC 为例,编译命令如下:

$ arm-cortex_a8-linux-gnueabihf-gcc shared-mem-demo.c -lrt -pthread -o arm-cortex_a8-linux-gnueabihf-gcc

综上所述,本文介绍了进程的终止、运行不同程序、守护进程以及进程间通信的多种方式,包括基于消息的进程间通信和基于共享内存的进程间通信,希望能帮助你更好地理解和应用这些知识。

进程与线程学习指南

7. 多线程进程展望

前面我们详细探讨了进程的各种操作以及进程间通信的多种方式,接下来我们将简单展望一下多线程进程。多线程进程允许一个进程内包含多个执行线程,这些线程可以并发执行,共享进程的资源,如内存、文件描述符等。多线程编程可以提高程序的性能和响应能力,尤其在处理并发任务时具有显著优势。

多线程编程涉及到线程的创建、同步和销毁等操作。例如,在 POSIX 标准中,可以使用 pthread_create() 函数创建线程,使用 pthread_join() 函数等待线程结束,使用信号量、互斥锁等机制实现线程间的同步。以下是一个简单的多线程示例代码:

#include <stdio.h>
#include <pthread.h>

// 线程函数
void *thread_function(void *arg) {
    printf("This is a new thread.\n");
    return NULL;
}

int main() {
    pthread_t thread_id;
    int result;

    // 创建线程
    result = pthread_create(&thread_id, NULL, thread_function, NULL);
    if (result != 0) {
        perror("Thread creation failed");
        return 1;
    }

    // 等待线程结束
    result = pthread_join(thread_id, NULL);
    if (result != 0) {
        perror("Thread join failed");
        return 1;
    }

    printf("Main thread exiting.\n");
    return 0;
}

在这个示例中, main 函数创建了一个新的线程,并等待该线程执行完毕后才退出。

8. 总结与对比

为了更清晰地理解进程间通信的各种方式,我们对前面介绍的几种方式进行总结和对比,如下表所示:
| 通信方式 | 数据复制情况 | 消息流方向 | 数据类型 | 消息优先级 | 适用场景 |
| ---- | ---- | ---- | ---- | ---- | ---- |
| Unix 套接字 | 需复制 | 双向 | 字节流或离散消息 | 无 | 大多数场景,注重可移植性 |
| FIFOs 和命名管道 | 需复制 | 单向 | 字节流 | 无 | 简单的单向数据传输 |
| POSIX 消息队列 | 需复制 | 双向 | 离散消息 | 有 | 对消息优先级有要求的场景 |
| POSIX 共享内存 | 无需复制 | - | - | - | 大量数据共享,需同步访问 |

从这个表格中,我们可以根据具体的应用场景选择合适的进程间通信方式。例如,如果需要在不同操作系统间进行通信,Unix 套接字是一个不错的选择;如果只是简单的单向数据传输,FIFOs 可能更合适;如果对消息优先级有要求,POSIX 消息队列则是首选;而当需要共享大量数据时,POSIX 共享内存可以避免数据复制带来的开销。

9. 操作流程回顾

为了帮助大家更好地掌握前面介绍的各种操作,我们再次回顾一下一些关键操作的流程。

9.1 创建守护进程流程
graph TD;
    A[调用 fork 创建新进程] --> B[父进程退出];
    B --> C[子进程调用 setsid(2) 创建新会话和进程组];
    C --> D[将工作目录更改为根目录];
    D --> E[关闭所有文件描述符并重定向 stdin、stdout、stderr 到 /dev/null];
    E --> F[守护进程创建完成];
9.2 重定向目录列表到文件流程
graph TD;
    A[在 shell 输入 ls > listing.txt] --> B[shell 复制自身];
    B --> C[子进程打开并截断 listing.txt 文件];
    C --> D[使用 dup2(2) 复制文件描述符到 stdout];
    D --> E[子进程执行 /bin/ls];
    E --> F[ls 程序将列表写入 listing.txt 文件];
    F --> G[ls 程序终止,shell 重新获得控制权];
10. 注意事项

在进行进程和线程编程时,还需要注意以下几点:
- 资源管理 :进程终止时会自动释放一些资源,但在某些情况下,如使用共享内存,需要手动管理资源的分配和释放,避免内存泄漏。
- 同步问题 :在使用共享内存或多线程编程时,同步问题至关重要。不正确的同步可能导致数据不一致、死锁等问题。
- 错误处理 :在调用系统函数时,如 fork() exec() 等,需要检查返回值并进行错误处理,以确保程序的健壮性。

通过本文的介绍,我们对进程的终止、运行不同程序、守护进程、进程间通信以及多线程编程有了更深入的了解。希望这些知识能帮助你在实际编程中更好地运用进程和线程,提高程序的性能和可靠性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值