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

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

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 <stdlib.h>

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 <stdlib.h>

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 <pthread.h>
#include <stdio.h>
#include <stdlib.h>

// функция, которая будет выполнена в потоке
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 <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#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 ввел явную поддержку атомарных операций через модуль <stdatomic.h>. Он предоставляет набор атомарных типов и операций для работы с ними.

Рассмотрим базовый пример безопасного увеличения счетчика из нескольких потоков:

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

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, или в случаях, когда поддержка <stdatomic.h> недоступна или нежелательна.

Реализуем атомарный обмен значений:

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

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.

Комментарии (12)


  1. kovserg
    17.03.2024 16:33
    +5

    Начнем с динамической памяти.

    А со статического выделения памяти не хотите начать? Особенно есть памяти мало и она разная.

    В С есть максимальная свобода и контроль над своим кодом

    К сожалению вы всегда будете вынуждены работать с чужим кодом.


  1. dyadyaSerezha
    17.03.2024 16:33
    +3

    Указатели на функции, типа-конструкторы/деструкторы и т.п. - не катит, потому что они не вызываются автоматически.


  1. includedlibrary
    17.03.2024 16:33
    +1

    В любом учебнике, отдельно что потокам, что управлению памятью больше информации даётся. Тут всё очень поверхностно, чисто для рекламы курсов. Ещё и RAII зачем-то натягивается на си. Стоит объяснять людям, что не любой ресурс надо освобождать. Если программа запускается, выполняет одну задачу и завершается, ничего освобождать не нужно, ОС всё сделает за вас. Если программа аллоцирует ресурсы, а потом работает с ними в цикле (и в цикле никакие ресурсы не выделяются), то тоже ничего освобождать не нужно. Для управления памятью можно использовать не стандартный malloc, а arena allocator, да не всегда, но когда можно, жизнь он упрощает, ещё и работает быстрее, чем malloc. Потому что не нужно следить за жизнью 1000 ресурсов, потому что можно одним вызовом free освободить сразу всю 1000. Иногда можно использовать scratch arena (не знаю, как на русский перевести), там вообще можно ничего не освобождать. Передал в функцию копию аллокатора (не ссылку), функция что-то там повыделяла, но менялась при этом только копия аллокатора, а значит после завершения функции, вся память, ею выделенная считается доступной. Надо про это рассказывать, а не про то, как натягивать сову RAII на глобус Си.

    UPD: ну и как выше заметили, про статическое выделение памяти тоже надо рассказывать. Многие вещи можно вообще без аллокаций сделать: например сетевые клиенты или игровые движки


  1. prik
    17.03.2024 16:33
    +11

    numbers = realloc(numbers, sizeof(int) * 20);

    if (numbers == NULL) {

    // обработка ошибки выделения памяти

    }

    Тут потенциальная утечка памяти в ветки обработки ошибки из-за потери оригинального значения numbers.


  1. syrus_the_virus
    17.03.2024 16:33
    +7

    "Функциональные указатели" и "двойные указатели" - не надо так, не пишите так больше, и уж тем более, не учите других так делать. В языке Си есть указатель на функцию, указатель на указатель: указатель - он сам по себе, это основополагающий элемент синтаксической связки машина-код, и не стоит его подменять пустыми высокоуровневыми абстракциями. Спасибо.


    1. domix32
      17.03.2024 16:33

      Я представляю как они изобретут n* указатели, когда там появится какой-нибудь указатель на ячейку n-мерной матрицы.


  1. 776166
    17.03.2024 16:33
    +3

    А давайте вы в OTUS не будете заниматься SMM-спамом? Тем более, что вы, судя по комментариям настоящих специалистов, читающих ваши маркетинговые обязаловки, не очень разбираетесь в вопросах, о которых пытаетесь писать. И без вас шума много. Сконцентрируйтесь на своём основном занятии.


    1. 9241304
      17.03.2024 16:33
      +1

      Хотел нюспросить, почему вторая подряд статья как будто двумя школьниками написана, но вы уже ответили. Спасибо


  1. KivApple
    17.03.2024 16:33

    Очень поверхностно.

    Например, для realloc не указано будет ли дополнительная память инициализирована нулями или нет, допущена утечка памяти в примере кода обработки ошибок.


  1. domix32
    17.03.2024 16:33

    Можно использовать фичи из C++ (что?)

    Так а какие фичи из с++ внезапно оказались в с? RAII так и не завезли, с инциализаторами проблема также не решена. Кликбейтнули подзаголовок и не солоно хлебавши перешли сразу к фабричным функциям. Что?


  1. Tujh
    17.03.2024 16:33
    +1

    Однако попробуем адаптировать RAII для C

    То чувство, когда ожидаешь увидеть практическое применение __attrubute(( __cleanup ... и прочего, а по факту просто пишут - а давайте напишем функцию, что бы удалять поля структуры...


  1. Free_ze
    17.03.2024 16:33

    попробуем адаптировать RAII для C

    IntArray_create и IntArray_destroy играют роли конструктора и деструктора.

    Это все те же обычные функции, которые все так же нужно не забывать вызывать в нужных местах руками. Сделали инстанс IntArray где-то вне IntArray_create - рискуете получить невалидный объект. Не вызвали вовремя IntArray_destroy потеряв указатель - ресурс утек. RAII нужен как раз затем, чтобы такого не происходило, но его тут нет.