目录
1 竞争冒险
1.1 竞争冒险由来
1.2 竞争冒险理解
2 原子操作
2.1 O_APPEND 实现原子操作
2.2 pread()和 pwrite()
2.3 O_EXCL 标志创建文件
1 竞争冒险
1.1 竞争冒险由来
Linux 是一个支持多任务和多用户同时运行的操作系统,它允许多个进程同时执行。在这种环境下,可能会有多个进程同时对同一个文件执行输入输出(IO)操作,使得该文件成为这些进程共享的资源。与直接在硬件上运行的裸机编程不同,Linux 操作系统提供了进程管理和多任务处理的功能。在裸机编程中,通常不会有进程和多任务的概念,然而在 Linux 系统中,需要特别注意在多进程环境中可能发生的竞争冒险,因为不同的进程可能会同时试图访问和修改共享资源,这可能导致数据不一致或程序行为异常。
1.2 竞争冒险理解
下面代码是一个典型的多线程竞争冒险场景。程序中定义了一个共享的整型变量shared_counter
,并将其声明为volatile
以防止编译器进行优化。increment
函数是线程执行的主体,它在每个线程中运行1000次,每次循环中调用usleep(10)
来模拟CPU密集型操作,然后对shared_counter
进行递增。在main
函数中,创建了10个线程,每个线程都执行increment
函数。程序使用pthread_create
来创建线程,并通过pthread_join
等待所有线程完成。最后,程序打印出预期的shared_counter
值(即NUM_THREADS * 1000
),以及实际的shared_counter
值。由于shared_counter
的递增操作不是原子的,并且没有使用同步机制来防止多个线程同时修改它,因此实际值可能会小于预期值,展示了竞争冒险的效果。
#include <pthread.h>
#include <stdio.h>
#include <unistd.h> // 引入usleep函数
// 共享资源
volatile int shared_counter = 0; // 使用volatile关键字防止编译器优化
// 线程函数
void* increment(void* arg) {
for (int i = 0; i < 1000; ++i) {
// 模拟一些CPU密集型的操作
usleep(10); // 让线程暂停一小段时间,增加线程调度的不确定性
// 非原子操作,可能导致竞争冒险
shared_counter++;
}
return NULL;
}
int main() {
const int NUM_THREADS = 10; // 增加线程数量
pthread_t threads[NUM_THREADS];
// 创建多个线程
for (int i = 0; i < NUM_THREADS; ++i) {
if (pthread_create(&threads[i], NULL, increment, NULL) != 0) {
perror("Error creating thread");
return 1;
}
}
// 等待所有线程结束
for (int i = 0; i < NUM_THREADS; ++i) {
pthread_join(threads[i], NULL);
}
// 输出共享变量的值
printf("Expected value: %d\nActual value: %d\n", NUM_THREADS * 1000, shared_counter);
return 0;
}
在这个修改后的程序中,每个线程只执行1000次递增操作,同时在每次递增操作前后添加了usleep(10)
,以模拟一些CPU密集型的操作并增加线程调度的不确定性。由于竞争冒险,shared_counter
的实际值很可能小于预期的值(在这个例子中是10000)。通过运行这个程序多次,你应该能够观察到不同的输出值,这些值通常小于预期值,从而演示了竞争冒险的影响。运行的结果如下:
2 原子操作
在Linux系统中,原子操作是一种确保在多线程环境下对共享数据进行安全访问的技术。原子操作的关键在于它们是不可分割的最小执行单元,即在执行过程中不会被其他线程中断。这使得原子操作成为避免竞争冒险(race conditions)和确保数据一致性的重要工具。
使用原子操作时,程序员不需要手动管理锁,因为原子操作本身提供了必要的同步。这简化了编程模型,并有助于避免死锁和优先级反转等并发问题。
2.1 O_APPEND 实现原子操作
O_APPEND
是一个在 Linux 系统中打开文件时使用的文件状态标志,它确保所有写操作都追加到文件末尾。虽然 O_APPEND
本身并不直接实现原子操作,但它确实保证了写操作的原子性,因为它防止了写入位置在多个进程或线程之间的竞争。
当多个进程或线程打开同一个文件并指定 O_APPEND
标志时,内核会负责管理文件写入指针,确保每个写操作都从文件的当前末尾开始。这意味着即使多个进程或线程同时写入同一个文件,每个写入操作也会被追加到文件的末尾,而不会相互干扰。
这里是一个简单的例子,演示如何使用 O_APPEND
来确保写操作的原子性:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = open("example.txt", O_WRONLY | O_APPEND);
if (fd == -1) {
perror("Failed to open file");
return 1;
}
const char* message = "This is a test message\n";
if (write(fd, message, strlen(message)) == -1) {
perror("Failed to write to file");
close(fd);
return 1;
}
close(fd);
return 0;
}
在这个例子中,使用 open
函数打开一个名为 "example.txt" 的文件,并指定 O_WRONLY
(只写模式)和 O_APPEND
标志。这确保了所有的 write
调用都会将数据追加到文件末尾。即使多个进程同时运行这段代码,每个进程的写操作也会被顺序地追加到文件末尾,而不会覆盖其他进程的写入内容。
需要注意的是,虽然 O_APPEND
确保了写入操作的顺序性,但它并不保证数据的完整性或写入操作的原子性。例如,如果一个写操作被分割成多个部分,O_APPEND
不能保证这些部分会连续地写入文件。对于需要确保数据完整性的场景,可能需要使用其他同步机制,如互斥锁(mutexes)或文件锁(file locking)。
2.2 pread()和 pwrite()
这两个函数需要包含头文件<unistd.h>。pread()和 pwrite()都是系统调用,与 read()、 write()函数的作用一样,用于读取和写入数据。区别在于,pread()和 pwrite()可用于实现原子操作,调用 pread 函数或 pwrite 函数可传入一个位置偏移量 offset 参数,用于指定文件当前读或写的位置偏移量,所以调用 pread 相当于调用 lseek 后再调用 read;同理,调用 pwrite相当于调用 lseek 后再调用 write。 所以可知, 使用 pread 或 pwrite 函数不需要使用 lseek 来调整当前位置偏移量,并会将“移动当前位置偏移量、读或写”这两步操作组成一个原子操作。
(1)pread()
函数
pread()
用于从文件中读取数据,而不改变文件的当前读指针。这意味着每次调用 pread()
,都会从指定的文件位置开始读取数据,而不影响其他读/写操作。
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
参数说明:
fd
: 文件描述符,表示要读取的文件。buf
: 指向一个缓冲区的指针,用于存储从文件中读取的数据。count
: 要读取的字节数。offset
: 文件中的偏移量,从这个位置开始读取数据。
(2)pwrite()函数
pwrite()
与 pread()
相对应,用于在文件中的指定位置写入数据,而不改变文件的当前写指针。
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
参数说明:
fd
: 文件描述符,表示要写入的文件。buf
: 指向要写入的数据的缓冲区的指针。count
: 要写入的字节数。offset
: 文件中的偏移量,从这个位置开始写入数据。
(3)示例代码
下面的示例代码展示了如何使用 pread()
和 pwrite()
函数:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
int fd;
const char *data = "Sample data";
char buffer[20];
ssize_t bytes_read, bytes_written;
// 创建或打开文件
fd = open("testfile.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
// 使用 pwrite() 写入数据
bytes_written = pwrite(fd, data, strlen(data), 0);
if (bytes_written == -1) {
perror("pwrite");
close(fd);
exit(EXIT_FAILURE);
}
// 移动文件指针到文件中间的某个位置
if (lseek(fd, 5, SEEK_SET) == -1) {
perror("lseek");
close(fd);
exit(EXIT_FAILURE);
}
// 使用 pread() 从指定位置读取数据
bytes_read = pread(fd, buffer, sizeof(buffer), 5);
if (bytes_read == -1) {
perror("pread");
close(fd);
exit(EXIT_FAILURE);
}
buffer[bytes_read] = '\0'; // 确保字符串终止
// 打印读取的数据
printf("Read data: %s\n", buffer);
// 关闭文件
close(fd);
return 0;
}
代码中首先打开(或创建)一个名为 testfile.txt
的文件用于读写。然后,我们使用 pwrite()
在文件开头写入一个字符串。接着,我们使用 lseek()
将文件指针移动到文件的第5个字节,然后使用 pread()
从这个位置读取数据到缓冲区中。最后,我们打印出读取的数据,并关闭文件。运行的结果如下:
2.3 O_EXCL 标志创建文件
创建文件中存在竞争状态,例如进程 A 和进程 B 都要去打开同一个文件、并且此文件还不存在。进程 A 当前正 在运行状态、进程 B 处于等待状态,进程 A 首先调用 open("./file", O_RDWR)函数尝试去打开文件,结果返 回错误,也就是调用 open 失败;接着进程 A 时间片耗尽、进程 B 运行,同样进程 B 调用 open("./file",O_RDWR)尝试打开文件,结果也失败,接着进程 B 再次调用 open("./file", O_RDWR | O_CREAT, ...)创建此 文件,这一次 open 执行成功,文件创建成功;接着进程 B 时间片耗尽、进程 A 继续运行,进程 A 也调用open("./file", O_RDWR | O_CREAT, ...)创建文件,函数执行成功。
创建文件中存在的竞争状态
从上面的示例可知,进程 A 和进程 B 都会创建出同一个文件,同一个文件被创建两次这是不允许的,那如何规避这样的问题呢? 那就是通过使用 O_EXCL 标志, 当 open 函数中同时指定了 O_EXCL 和O_CREAT 标志,如果要打开的文件已经存在,则 open 返回错误;如果指定的文件不存在,则创建这个文件,这里就提供了一种机制,保证进程是打开文件的创建者,将“判断文件是否存在、创建文件”这两个步骤合成为一个原子操作。