Process Priority Mechanics
In the Linux kernel, process metadata is stored within a structure known as task_struct. One critical field within this structure is the priority value, which dictates the sequence in which processes access CPU resources. Unlike permission bits, which determine whether a resource can be accessed, priority dictates the order of access among competing processes.
Since CPU resources are finite while process count is often high, the operating system employs a scheduling algorithm to ensure fairness. Without proper prioritization, processes might suffer from starvation, where a low-priority task waits indefinitely for CPU time.
Inspecting and Modifying Priority
Linux assigns a default priority to processes. To view these details, one can inspect the process list. The output typically includes PRI (Priority) and NI (Nice value). The Nice value acts as an offset to the base priority, allowing dynamic adjustment.
The calculation generally follows: New Priority = Base Priority + Nice Value.
Users can modify the Nice value using utilities like top or renice. However, to prevent system instability, the Nice value is constrained (typically between -20 and 19). Attempting to set a value outside this range results in clamping to the nearest valid limit.
Concurrency Concepts
- Competitiveness: System processes must vie for limited CPU resources.
- Independence: Each process operates in isolation; one process's crash generally does not directly corrupt another's memory.
- Parallelism: Multiple processes executing simultaneously on multiple CPU cores.
- Concurrency: Multiple processes making progress on a single CPU via rapid context switching.
Command Line Arguments
The main() function in C/C++ is often defined to accept arguments passed from the command shell:
int main(int arg_count, char *arg_vector[]) {
// Implementation
return 0;
}
Here, arg_count (argc) represents the number of arguments, and arg_vector (argv) is an array of strings holding the arguments. The first element (argv[0]) is always the program's name. Subsequent elements are the options provided by the user.
The shell (e.g., Bash) parses the input string, splitting it by spaces and terminating each segment with a null character. It then constructs the argv array and spawns a new process to execute the program, passing these arguments along.
Example: Argument Parsing
#include <stdio.h>
#include <string.h>
int main(int arg_count, char *arg_vector[]) {
if (arg_count != 2) {
printf("Usage: %s [-a|-b|-c]\n", arg_vector[0]);
return 1;
}
if (strcmp(arg_vector[1], "-a") == 0) {
printf("Function A executed.\n");
} else if (strcmp(arg_vector[1], "-b") == 0) {
printf("Function B executed.\n");
} else {
printf("Unknown option.\n");
}
return 0;
}
Environment Variables
Environment variables are dynamic values that affect the behavior of processes without requiring code changes. They exist globally within the user's session and are loaded into memory upon login.
The PATH Variable
When a command is executed without a full path (e.g., ls instead of /usr/bin/ls), the shell searches through the directories listed in the PATH variable. Users can temporarily add directories to this path:
export PATH=$PATH:/new/directory/path
To make these changes persistent, they must be added to configuration files like .bashrc or .bash_profile located in the user's home directory.
Accessing Environment Variables in Code
Programs can access these variables using the third argument to main(), the global environ variable, or the getenv() library function.
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[], char *envp[]) {
// Method 1: using the third argument
for (int i = 0; envp[i] != NULL; i++) {
printf("Env[%d]: %s\n", i, envp[i]);
}
// Method 2: using getenv
char *path_val = getenv("PATH");
if (path_val) {
printf("Current PATH: %s\n", path_val);
}
return 0;
}
Environment variables are inherited by child processes. Commands like export and cd are often shell builtins, meaning they are executed by the shell process itself rather than spawning a child process, ensuring the changes affect the current shell session.
Process Address Space
Consider a scenario where a parent process forks a child. Both access a global variable. Initially, both display the same value and the same virtual address. If the child modifies the variable, the value changes, but the virtual address remains identical to the parent's view.
This phenomenon occurs because the address printed is a virtual address, not a physical one.
Virtual Memory and Page Tables
The Operating System maintains a virtual address space for each process. This space is divided into regions (stack, heap, data, code). A page table maps these virtual addresses to physical memory frames.
When fork() is called, the child initially receives a copy of the parent's page tables pointing to the same physical memory (read-only sharing). This optimizes memory usage.
Copy-On-Write
When either process attempts to write to the shared memory, the OS detects the write attempt. It triggers a mechanism called Copy-On-Write:
- The OS allocates a new physical page.
- It copies the original data to the new page.
- It updates the child's page table to map the virtual address to the new physical page.
- The write operation proceeds.
This ensures process independence—the parent's data remains unchanged while the child's data diverges—without the overhead of duplicating all data immediately during the fork.
Why Virtual Addressing?
- Order vs. Chaos: Physical memory allocation is fragmented and non-linear. Virtual address space provides a consistent, linear view for the process, simplifying compilation and linking.
- Decoupling: It separates process management from physical memory management. The OS can swap memory pages to disk without the process knowing, as long as it updates the page tables.
- Protection: The MMU (Memory Management Unit) checks permissions (read/write/execute) via the page table. A process cannot accidentally overwrite kernel memory or another process's memory, preventing system crashes.
Code and Virtual Addresses
Addresses found in compiled binaries are virtual addresses. The loader and OS work together to map these to physical locations. This is why the code segment can be shared (read-only) across multiple instances of the same program, while data segments are handled via Copy-On-Write.