Основы управления ресурсами в C
Привет, Хабр!
Управлении ресурсами включает в себя распределение, использование и освобождение различных типов ресурсов. В языке программирования C автоматическое управление памятью отсутствует, эта задача ложится на плечи разработчиков.
В этой статьи рассмотрим основные возможности для работы с ресурсами в C.
Начнем с динамической памяти.
Работа с динамической памятью
malloc
предоставляет сырую память без инициализации. Подходит для тех моментов, когда нужен контроль и гибкость. Но здесь нужно не забывать о инициализации выделенной памяти, чтобы избежать неопределенного поведения:
int *numbers = malloc(sizeof(int) * 10);
if (numbers == NULL) {
// обработка ошибки выделения памяти
}
calloc
подходит, когда нужно работать с массивами или структурами данных, где начальное нулевое состояние предпочтительно.
int *matrix = calloc(10, sizeof(int));
if (matrix == NULL) {
// обработка ошибки выделения памяти
}
С realloc
можно перевыделить уже выделенную память, изменяя ее размер:
numbers = realloc(numbers, sizeof(int) * 20);
if (numbers == NULL) {
// обработка ошибки выделения памяти
}
free
помогает вернуть выделенную память обратно в систему. После вызова free
любые операции с этим указателем становятся невозможными:
free(numbers);
numbers = NULL; // обнуляем указатель для безопасности
Функциональные и двойные указатели
Функциональные указатели позволяют хранить адреса функций и вызывать их по этим адресам.
Пример кода:
void hello() {
printf("Привет, Хабр!\n");
}
void world() {
printf("Мир C!\n");
}
int main() {
void (*funcPtr)(); // объявление функционального указателя
funcPtr = &hello; // присвоение адреса функции hello
funcPtr(); // вызов функции hello
funcPtr = &world; // смена указателя на функцию world
funcPtr(); // вызов функции world
return 0;
}
Двойные указатели используются для управления памятью и структурами данных, такими как динамические массивы массивов. Они также юзабельны при передаче адреса указателя в функцию для его изменения. Пример работы с двойными указателями:
#include
#include
void allocateArray(int **arr, int size, int value) {
*arr = (int*)malloc(size * sizeof(int));
for(int i = 0; i < size; i++) {
*(*arr + i) = value;
}
}
int main() {
int *array = NULL;
allocateArray(&array, 5, 45); // выделяем память и инициализируем массив
for(int i = 0; i < 5; i++) {
printf("%d ", array[i]);
}
free(array); // не забываем освободить память
return 0;
}
Valgrind и санитайзеры
Valgrind ищет неинициализированную память, ошибки работы с динамической памятью и многое другое, а после поиска сообщает об ошибках.
Например:
#include
int main() {
int *a = malloc(sizeof(int) * 10); // здесь мы выделили память...
// ...и забыли ее освободить. Опечатка или судьба?
return 0;
}
Запустив Valgrind с этим кодом, получим нечто вроде:
==12345== LEAK SUMMARY:
==12345== definitely lost: 40 bytes in 1 blocks
Санитайзеры также помогают нам обнаружить утечки памяти, не выходя из IDE. Например, AddressSanitizer, один из таких инструментов, может быть использован следующим образом:
gcc -fsanitize=address -g your_program.c
Это не позволит пройти незаметно утечкам памяти.
Можно использовать фичи из C++ (что?)
Как вы, возможно, знаете, RAII — это паттерн из C++, где при создании объекта ресурс захватывается, а при уничтожении объекта — освобождается. «Но стоп,» — скажете вы, — «в C нет классов и деструкторов!». Однако попробуем адаптировать RAII для C, используя структуры и функции очистки.
Представим, что есть структура для управления динамической памятью:
#include
typedef struct {
int* array;
size_t size;
} IntArray;
IntArray* IntArray_create(size_t size) {
IntArray* ia = malloc(sizeof(IntArray));
if (ia) {
ia->array = malloc(size * sizeof(int));
ia->size = size;
}
return ia;
}
void IntArray_destroy(IntArray* ia) {
if (ia) {
free(ia->array);
free(ia);
}
}
IntArray_create
и IntArray_destroy
играют роли конструктора и деструктора.
Еще один интересный паттерн — фабричные функции. Они позволяют абстрагироваться от процесса создания объектов, скрывая детали инициализации. В C это могут быть функции, возвращающие указатели на различные структуры, представляющие ресурсы:
typedef struct {
FILE* file;
} FileResource;
FileResource* FileResource_open(const char* filename, const char* mode) {
FileResource* fr = malloc(sizeof(FileResource));
if (fr) {
fr->file = fopen(filename, mode);
}
return fr;
}
void FileResource_close(FileResource* fr) {
if (fr) {
fclose(fr->file);
free(fr);
}
}
Параллелизм и многопоточность
Используя pthreads, можно разделить задачи на несколько потоков, которые выполняются параллельно. Создадим поток для выполнения простой функции, которая выводит сообщение на экран:
#include
#include
#include
// функция, которая будет выполнена в потоке
void* thread_function(void* arg) {
printf("Привет из потока! Аргумент функции: %s\n", (char*)arg);
return NULL;
}
int main() {
pthread_t thread_id;
char* message = "Thread's Message";
// создаем поток
if(pthread_create(&thread_id, NULL, thread_function, (void*)message)) {
fprintf(stderr, "Ошибка при создании потока\n");
return 1;
}
// ожидаем завершения потока
if(pthread_join(thread_id, NULL)) {
fprintf(stderr, "Ошибка при ожидании потока\n");
return 2;
}
printf("Поток завершил работу\n");
return 0;
}
pthread_create
запускает новый поток, который выполняет thread_function
, в то время как pthread_join
ожидает завершения этого потока.
Разделив массив на части и обработав каждую часть в отдельном потоке, мы можем значительно ускорить процесс. Суммируем элементы большого массива, используя многопоточность:
#include
#include
#include
#define SIZE 1000000
#define THREADS 4
int array[SIZE];
long long sum[THREADS] = {0};
void* sum_function(void* arg) {
int thread_part = (int)arg;
int start = thread_part * (SIZE / THREADS);
int end = (thread_part + 1) * (SIZE / THREADS);
for(int i = start; i < end; i++) {
sum[thread_part] += array[i];
}
return NULL;
}
int main() {
pthread_t threads[THREADS];
// инициализация массива
for(int i = 0; i < SIZE; i++) {
array[i] = i + 1;
}
// создание потоков для суммирования частей массива
for(int i = 0; i < THREADS; i++) {
pthread_create(&threads[i], NULL, sum_function, (void*)i);
}
// ожидание завершения всех потоков
for(int i = 0; i < THREADS; i++) {
pthread_join(threads[i], NULL);
}
// суммирование результатов
long long total_sum = 0;
for(int i = 0; i < THREADS; i++) {
total_sum += sum[i];
}
printf("Общая сумма: %lld\n", total_sum);
return 0;
}
Код делит массив на четыре части и вычисляет сумму каждой части в отдельном потоке, после чего суммирует полученные результаты.
Атомарные операции
Атомарные операции часто реализуются с помощью спецификаций, предоставляемых как часть стандарта C11 или с помощью расширений, предоставляемых компиляторами, например GCC.
Стандарт C11 ввел явную поддержку атомарных операций через модуль
. Он предоставляет набор атомарных типов и операций для работы с ними.
Рассмотрим базовый пример безопасного увеличения счетчика из нескольких потоков:
#include
#include
#include
atomic_int counter = ATOMIC_VAR_INIT(0);
void* increment(void* arg) {
for (int i = 0; i < 100000; ++i) {
atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);
}
return NULL;
}
int main() {
pthread_t t1, t2;
if(pthread_create(&t1, NULL, increment, NULL)) {
return 1;
}
if(pthread_create(&t2, NULL, increment, NULL)) {
return 1;
}
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Значение счетчика: %d\n", atomic_load(&counter));
return 0;
}
atomic_int
используется для объявления счетчика, а atomic_fetch_add_explicit
— для атомарного увеличения его значения. memory_order_relaxed
указывает, что операция может быть реорганизована по отношению к другим операциям, но гарантируется атомарность самой операции увеличения.
GCC предлагает расширения для атомарных операций, которые можно использовать в версиях C, предшествующих C11, или в случаях, когда поддержка
недоступна или нежелательна.
Реализуем атомарный обмен значений:
#include
#include
int counter = 0;
void* increment(void* arg) {
for (int i = 0; i < 100000; ++i) {
__sync_fetch_and_add(&counter, 1);
}
return NULL;
}
int main() {
pthread_t t1, t2;
if(pthread_create(&t1, NULL, increment, NULL)) {
return 1;
}
if(pthread_create(&t2, NULL, increment, NULL)) {
return 1;
}
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Значение счетчика: %d\n", counter);
return 0;
}
Фнкция __sync_fetch_and_add
из расширений GCC используетсядля атомарного увеличения значения счетчика.
В С есть максимальная свобода и контроль над своим кодом, но вместе с этим приходит и ответственность. Используйте эту свободу с умом, осознавая последствия своих решений.
По ссылке вы сможете зарегистрироваться на бесплатный вебинар курса «Программист С» про реализацию динамических структур данных на С и Python.