Основы управления ресурсами в C

0ee30197fce8a0c62b3a466275b321c9.png

Привет, Хабр!

Управлении ресурсами включает в себя распределение, использование и освобождение различных типов ресурсов. В языке программирования 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.

© Habrahabr.ru