Skip to main content

2 posts tagged with "file"

View All Tags

Thiago Lages de Alencar

Ano passado decidi explorar como garantir escrever em um arquivo sem que outros processos atrapalhem. Isto levou a criação do post: Lock Files Process.

Agora o problema que tenho refletido é sobre como editar arquivos de forma segura.

Problem 1

Se eu possuo apenas um arquivo, eu posso utilizar locks do sistema operacional para evitar que outros processos leiam/escrevam enquanto eu estou escrevendo.

process_1
└── file_a.json (get lock)
process_2
└── file_a.json (wait lock)
note

Isso não impede que processos má intencionados corrompam seu arquivo, pois eles ainda podem ignorar estas locks.

Mas o que acontece se tivermos no meio de escrever um arquivo grande e o computador desligar/travar? Existe o risco do nosso arquivo ficar escrito pela metade...

Problem 2

Vamos supor que temos a solução do problema acima, como solucionamos para casos onde um comando da CLI precisa alterar diversos arquivo e o computador desliga/trava no meio?

process_1
├── file_a.json (editing)
├── file_b.json (editing)
└── file_c.json (editing)

Isto quer dizer que pode travar após terminar de editar um arquivo enquanto os outros não:

process_1
├── file_a.json (edited)
├── file_b.json (editing)
└── file_c.json (editing)

Ler do arquivo file_a.json poderia nos dar a ilusão que o comando da CLI funcionou quando na realidade nem tudo foi aplicado.

Problem 3

O outro problema que tenho refletido é quando múltiplos processos precisam alterar múltiplos arquivos ao mesmo tempo.

process_1 precisa utilizar file_a.json e file_b.json
process_2 precisa utilizar file_a.json e file_b.json

process_1
├── file_a.json (get lock)
└── file_b.json (get lock)
process_2
├── file_a.json (wait lock)
└── file_b.json

Neste caso nenhum problema ocorre pois process_2 vai ficar esperando process_1 liberar a lock de file_a.json para continuar com suas tarefas. Isso é o mesmo que executar os processos sequencialmente.

process_1 precisa utilizar file_a.json e file_b.json
process_2 precisa utilizar file_b.json e file_c.json

process_1
└── file_a.json (get lock)
process_2
└── file_b.json (get lock)
process_1
└── file_b.json (wait lock)
process_2
└── file_c.json (get lock)

Neste caso, mesmo process_1 começando primeiro, ele vai ter que esperar process_2 liberar a lock que foi pega no meio da execução dele. Isso também não é um caso ruim, pois no final a lock deve ser liberada.

process_1 precisa utilizar file_a.json e file_b.json
process_2 precisa utilizar file_b.json e file_a.json

process_1
└── file_a.json (get lock)
process_2
└── file_b.json (get lock)
process_1
└── file_b.json (wait lock)
process_2
└── file_a.json (wait lock)

É neste caso em que um deadlock ocorre. Onde ambos processos ficam esperando um pelo o outro e podem nunca sair do loop.

Mesmo que eu considere qualquer timeout que os processos tenham, nada impede de acontecer novamente e novamente... Então não é ideal só esperar que não aconteça.

Solution 1

Uma maneira de solucionar isto é escrever em arquivos temporários e no final substituir os originais.

process_1
├── file_a.json
└── file_a.temp

Veja bem, enquanto você escreve nos arquivos temporários, os arquivos originais vão ficar intactos e sem risco de serem corrompidos em caso de falhas no sistema. Quando finalmente você terminar de escrever o que queria neles, você substitui os originais utilizando uma operação atómica (mv ou rename).

Basicamente a lógica é:

  • Dado que temos o arquivo original (xxxx.json)
  • Copie o conteúdo do arquivo original para o temporário (xxxx.temp)
  • Escreva sempre no arquivo temporário
  • Substitua o arquivo original pelo temporário

Se considerarmos que temos locks:

  • Dado que temos o arquivo original (xxxx.json)
  • Pegue a lock do arquivo original
  • Copie o conteúdo do arquivo original para o temporário (xxxx.temp)
  • Escreva sempre no arquivo temporário
  • Substitua o arquivo original pelo temporário
  • Solte a lock do arquivo original
warning

Ambas operações mv e rename são atómicas quando se trata do mesmo volume (HD/SSD) pois é só um redirecionamento do ponteiro. Se estivermos falando dessas operações entre volumes diferentes, seria preciso que um volume copiasse para o outro volume antes e isso não é atómico.

Porém como estamos lidando com locks, e é necessário saber se podemos renomear um arquivo com lock... A resposta é sim para Linux/Macos e não para Windows.

Isso ocorre pois Windows utiliza mandatory lock (em vez de advisory lock), isso obriga todos os processos a respeitarem o acesso aquele arquivo e proibi a remoção do arquivo enquanto possuir uma lock.

Solution 1+

Podemos contornar esse problema utilizando um arquivo de acesso como lock.

process_1
├── file_a.json
├── file_a.lock
└── file_a.temp

Agora nos criamos a regra interna que apenas aqueles com a lock do arquivo de acesso podem escrever no arquivo.

  • Dado que temos o arquivo original (xxxx.json)
  • Pegue a lock do arquivo de acesso (xxxx.lock)
  • Copie o conteúdo do arquivo original para o temporário (xxxx.temp)
  • Escreva sempre no arquivo temporário
  • Substitua o arquivo original pelo temporário
  • Solte a lock do arquivo de acesso

Solution 1++

Existe um pequeno detalhe que eu esqueci de cobrir... Enquanto operações as operações rename e mv são atómicas na memória (RAM), isso não quer dizer que sua mudanças já foram para o storage (HD/SSD).

CPU trabalha com a memória e de tempos em tempos armazena as alterações no storage, ou seja, a gente chama rename e todos os processos já vão poder ver isso como verdade... Mas não quer dizer que as mudanças já foram salvas no storage.

Por isto que precisamos utilizar comandos como fsync que forção o flush dos dados no nosso storage.

  • Dado que temos o arquivo original (xxxx.json)
  • Pegue a lock do arquivo de acesso (xxxx.lock)
  • Copie o conteúdo do arquivo original para o temporário (xxxx.temp)
  • Escreva sempre no arquivo temporário
  • fsync no arquivo temporário
  • Substitua o arquivo original pelo temporário
  • fsync no diretório do arquivo original
  • Solte a lock do arquivo de acesso

O primeiro fsync garante que o arquivo temporário esteja storage antes de substituir o original.

danger

Imagina se a gente usasse rename, sem ele ter sido armazenado no storage!

Você apenas está substituindo os ponteiros quando faz um rename, então é bom você ter certeza que o ponteiro seja para um espaço do drive com conteúdo (caso contrário você está apontando para lixo).

O segundo fsync atualiza o metadata do diretório (os arquivos que ele possue).

Solution 2

Para garantir a atomicidade dessa alteração em conjunto, podemos criar um journal que irá armazenar todos os arquivos que precisam ser substituidos e seus estados.

Iremos definir 3 estados para nossos arquivos temporários:

  • WRITE
    • Processo pode estar copiando o arquivo original
    • Processo pode estar alterando o arquivo temporário
  • REPLACE
    • Processo não vai fazer mais alterações no arquivo temporário
    • Processo está substituindo o arquivo original pelo temporário
  • DELETE
    • Processo já substituiu o arquivo original
    • Processo está deletando o arquivo temporário

Os arquivos temporário caminham pelos estados na ordem WRITE, REPLACE, DELETE.

process_1
└── journal
├── file_a.temp (WRITE)
├── file_b.temp (WRITE)
└── file_c.temp (WRITE)

Encontrar um arquivo no estado WRITE indica que o processo nunca terminou de escrever os temporários, então você pode descartar todos os arquivos temporários pois não é possível continuar de onde a tarefa parou.

process_1
└── journal
├── file_a.temp (REPLACE)
├── file_b.temp (REPLACE)
└── file_c.temp (REPLACE)

Encontrar um arquivo no estado REPLACE indica que é possível terminar a tarefa pois o arquivo temporário já terminou de ser alterado, basta você terminar de substituir o arquivo.

process_1
└── journal
├── file_a.temp (DELETE)
├── file_b.temp (DELETE)
└── file_c.temp (DELETE)

Encontrar um arquivo no estado DELETE indica que ele precisa ser removido pois já foi utilizado como deveria.

É importante notar que todos os arquivos devem caminhar em conjunto pelos estados. Por exemplo:

process_1
└── journal
├── file_a.temp (REPLACE)
├── file_b.temp (WRITE)
└── file_c.temp (WRITE)

O arquivo file_a.temp não pode ir para o estado DELETE até que todos os outros arquivos estejam no mesmo estado que ele (REPLACE).

A ausência dessa regra pode criar cenários impossíveis de se recuperar após uma falha no sistema. Por exemplo:

process_1
└── journal
├── file_a.temp (DELETE)
├── file_b.temp (WRITE)
└── file_c.temp (WRITE)
  • Não temos como reverter a alteração no file_a.temp pois já substituimos o arquivo
  • Não podemos completar de escrever o conteúdo de file_b.temp e file_c.temp pois não sabemos o que estava sendo escrito

Escrever no journal toda vez que um arquivo mudar de estado seria custoso demais e não mudaria nossa decisão final sobre os arquivos:

// Precisariamos descartar os arquivos.
process_1
└── journal
├── file_a.temp (WRITE)
├── file_b.temp (WRITE)
└── file_c.temp (WRITE)

// Precisariamos descartar os arquivos.
process_1
└── journal
├── file_a.temp (REPLACE)
├── file_b.temp (WRITE)
└── file_c.temp (WRITE)

// Precisariamos descartar os arquivos.
process_1
└── journal
├── file_a.temp (REPLACE)
├── file_b.temp (REPLACE)
└── file_c.temp (WRITE)

Então apenas vamos escrever quando todos estiverem no mesmo estado, pois só nesse caso a decisão de tratamento mudaria.

O próximo problema que temos é... Onde armazenar esse journal? Outro processos vão precisar ver ele para saber se precisam terminar alguma operação passada.

Solution 2+

Podemos armazenar o journal de cada processo em um arquivo dentro de um diretório qualquer (iremos chamar o diretório de .journals).

.journals
├── <pid_0001>.journal
├── <pid_0002>.journal
└── <pid_0003>.journal

Utilizamos o PID de cada processo como nome dos arquivos (pois eles são únicos).

Agora precisamos garantir o tratamento de qualquer journal incompleto, para isto vamos definir a simples regra que a primeira coisa a se fazer no programa é:

  • Verifica se outro jornal incompleto existe
    • Se existir, nós iniciamos o tratamento daquele journal

Mas como sabemos se um journal está incompleto? Como sabemos se já existe alguém fazendo o tratamento dele?

Solution 2++

Podemos utilizar locks para informar a outros processos que aquele journal está em tratamento.

.journals
├── <pid_0001>.journal (write lock)
├── <pid_0002>.journal (write lock)
└── <pid_0003>.journal (no lock)

Se o processo encontrar um journal sem lock, nós teremos certeza que ninguém está tratando ele no momento e devemos pegar o lock para nós e terminar o journal.

Solution 3

Para garantir que não exista deadlock, precisamos garantir que um processo pegue suas locks antes de outro processo tentar pegar as deles.

Para isto criaremos um arquivo que chamaremos de .global.lock, o qual todos os processos devem tentar pegar a lock antes de iniciar as tarefas.

  • Se nosso processo não conseguir a lock do .global.lock
    • Existe um processo pegando locks, devemos aguardar
  • Caso contrário
    • Nós podemos pegar as locks que são necessárias para nossa tarefa

É importante que essa lock seja liberada após aquele processo pegar as locks que precisa (ou falhar em pegar), caso contrário diversos processos podem acabar esperando pela liberação da .global.lock.

Conclusion

Eu não comecei a escrever nada de código ainda mas posso garantir uma coisa... Vai ser lento.

  • Clonar o arquivo original
  • Read & Write no journal o tempo todo
  • Lock & Unlock arquivos o tempo todo

Tudo é receita para ser bem lento, não é atoa que SQLite faz tudo em um arquivo e trabalha maior parte na memória RAM.

Dito isto, isto vai ser um projeto interessante para mim.

Rosto feliz

References

Thiago Lages de Alencar

Imagem mostrando camada do App, OS e Hardwares

Quando você quer ler o arquivo, você pede ao sistema operacional pelo conteúdo do arquivo.

Quando você quer escrever no arquivo, você pede ao sistema operacional para inserir o conteúdo no arquivo.

É importante saber que o sistema operacional toma diversos cuidados para que desenvolvedores não acessem diretamente o hardware, ou seja, por baixo dos panos você está pedindo para o sistema operacional ler/escrever.

  • C
    • fgets()
    • fwrite()
  • Python
    • file.read()
    • file.write()
  • Rust
    • file.read_to_string()
    • file.write_all()
  • Go
    • os.ReadFile()
    • os.WriteFile()

Veremos como garantir a segurança de um arquivo quando se tem múltiplos processos querendo altera-lo.

Process

A função utilizada para se criar processos é fork(), está função faz com que o atual processo crie um processo filho quase idêntico e executando o mesmo código que o pai.

Olhe este código que printa duas vezes "Hi":

#include <stdio.h>
#include <unistd.h>

int main() {
fork();
printf("Hi\n");
}

Se você executa-lo irá notar que o filho é tão igual ao pai que ele continua exatamente do mesmo local que o pai se encontrava (logo após fork() retornar um valor). Se tivessemos variáveis, poderiamos ver que até o valor delas são idênticos ao do pai.

No entanto, precisamos de uma maneira de reconhecer quem é o pai e filho, caso contrário este código executária exatamente a mesma coisa para ambos (não seria nada produtivo). Acontece que a função fork() retorna um valor e este valor é utilizado para sabermos se estamos no pai ou no filho.

#include <stdio.h>
#include <unistd.h>

int main() {
int pid = fork();

if (pid == -1) {
printf("Failed to create child process\n");
} else if (pid == 0) {
printf("I'm the child process\n");
} else {
printf("I'm the parent process\n");
}
}

A função fork() vai retornar ao pai o PID do filho (ou -1 em caso de error).
A função fork() vai retornar ao filho zero.

tip

Normalmente o código do pai e filho são inseridos em funções em vez de deixar tudo dentro de um if/else.

if(pid == 0) {
child_code();
} else {
parent_code();
}

Ou se utiliza funções exec para transformar completamente o código executado naquele processo.

Problem

Imagem ilustrativa do app 1 lendo do arquivo, app 2 lendo do arquivo, app 1 escrevendo no arquivo, app 2 escrevendo no arquivo

Quando dois processos interagem com o mesmo arquivo, pode acontecer da informação ser preenchida incorretamente? Afinal, precisamos primeiramente descobrir se isso é possível ou não de acontecer.

Como o escalonamento pode ser imprevisivel, uma maneira de testar se durante a interação com um arquivo houve troca de processo é repetindo a ação diversas vezes e ver se pelo menos uma vez ocorreu.

O seguinte código irá ser executado para o processo pai e filho:

int count = 0;
FILE* file = fopen("example.txt", "w+");

while(count < 10000) {
int i;

fseek(file, 0, SEEK_SET);
fscanf(file, "%d", &i);
fseek(file, 0, SEEK_SET);
fprintf(file, "%d ", ++i);

count++;
}

fclose(file);

O código irá garantir que o cursor está no início do arquivo, ler o atual número do arquivo, mover o cursor para o início do arquivo e sobreescrever o número.

note
fprintf(file, "%d     ", ++i);

Por que inserir espaço após o número? Foi uma maneira de evitar que o número de ambos processos se misturem.

Por exemplo: Processo 1 escreve 5000 e processo 2 escreve 9, o arquivo irá conter "9000" pois o 9 foi escrito em cima do 5.

Agora só precisamos adicionar a lógica de criar processo vista anteriormente:

#include <stdio.h>
#include <unistd.h>

void code() {
int count = 0;
FILE* file = fopen("example.txt", "w+");

while(count < 10000) {
int i;

fseek(file, 0, SEEK_SET);
fscanf(file, "%d", &i);
fseek(file, 0, SEEK_SET);
fprintf(file, "%d ", ++i);

count++;
}

fclose(file);
}

int main() {
FILE* file = fopen("example.txt", "w");
fputc('0', file);
fclose(file);

int pid = fork();

if (pid == -1) {
printf("Failed to create child process\n");
} else if (pid == 0) {
code();
printf("Child finished\n");
} else {
code();
printf("Parent finished\n");
}
}

Quando executei este código para 10 iterações, o valor final do arquivo foi 20.
Quando executei este código para 1000 iterações, o valor final do arquivo foi 1000.
Quando executei este código para 10000 iterações, o valor final do arquivo foi 10015.

O que somos capaz de deduzir com isto?

  • O resultado é imprevisível pois não temos controle de quando o escalonador vai trocar os processos
  • Dependendo do volume de iterações e da máquina do usuário, um processo pode ou não conseguir fazer a tarefa antes do escalonador trocar o processo
  • Se houver troca durante uma tarefa, pode corromper o resultado do arquivo

Quais as chances disto acontecer? Depende do software, pois existem arquivos que a chance de dois softwares interagirem ao mesmo tempo é 0%.

Locks

Acontece que existe mais de uma maneira de aplicar locks no Linux.

Uma pessoa bem surpresa

  • flock
    • file lock
    • Origem do sistema operacional BSD
  • lockf
    • lock file
    • POSIX
    • É uma versão simplificada do fcntl "Advisory record locking"
  • fcntl "Advisory record locking"
    • file control
    • POSIX
    • Uma função capaz de fazer diversas operações sobre file descriptors
      • Uma delas é utilizar "Advisory record locking"
  • fcntl "Open file description locks (non-POSIX)"
    • file control
    • Linux specific
      • Existem propostas para ser adicionado ao padrões POSIX
    • Uma função capaz de fazer diversas operações sobre file descriptors
      • Uma delas é utilizar "Open file description locks (non-POSIX)"

Se quiser saber mais sobre cada um, é bom ler o post no blog: https://gavv.net/articles/file-locks/
Incrivel como um blog de 8 anos atrás me ajudou mais do que pesquisas no Google.

flock

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/file.h>
#include <unistd.h>

#define BUFFER_SIZE 256

void code() {
int count = 0;
char *buffer = malloc(sizeof(char) * BUFFER_SIZE);
int fd = open("example.txt", O_RDWR);

while (count < 10000) {
int i;

flock(fd, LOCK_EX);
lseek(fd, 0, SEEK_SET);
read(fd, buffer, BUFFER_SIZE);
i = atoi(buffer) + 1;
sprintf(buffer, "%d ", i);
lseek(fd, 0, SEEK_SET);
write(fd, buffer, strlen(buffer));
flock(fd, LOCK_UN);

count++;
}

close(fd);
}

int main() {
FILE *file = fopen("example.txt", "w");
fputc('0', file);
fclose(file);

int pid = fork();

if (pid == -1) {
printf("Failed to create child process\n");
} else if (pid == 0) {
code();
printf("Child finished\n");
} else {
code();
printf("Parent finished\n");
}
}

lockf

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define BUFFER_SIZE 256

void code() {
int count = 0;
char *buffer = malloc(sizeof(char) * BUFFER_SIZE);
int fd = open("example.txt", O_RDWR);

while (count < 10000) {
int i;

lockf(fd, F_LOCK, 0);
lseek(fd, 0, SEEK_SET);
read(fd, buffer, BUFFER_SIZE);
i = atoi(buffer) + 1;
sprintf(buffer, "%d ", i);
lseek(fd, 0, SEEK_SET);
write(fd, buffer, strlen(buffer));
lockf(fd, F_ULOCK, 0);

count++;
}

close(fd);
}

int main() {
FILE *file = fopen("example.txt", "w");
fputc('0', file);
fclose(file);

int pid = fork();

if (pid == -1) {
printf("Failed to create child process\n");
} else if (pid == 0) {
code();
printf("Child finished\n");
} else {
code();
printf("Parent finished\n");
}
}

fcntl - "Advisory record locking"

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define BUFFER_SIZE 256

void code() {
int count = 0;
struct flock fl;
char *buffer = malloc(sizeof(char) * BUFFER_SIZE);
int fd = open("example.txt", O_RDWR);

fl.l_whence = SEEK_SET;
fl.l_start = 0;
fl.l_len = 0;

while (count < 10000) {
int i;

fl.l_type = F_WRLCK;
fcntl(fd, F_SETLKW, &fl);
lseek(fd, 0, SEEK_SET);
read(fd, buffer, BUFFER_SIZE);
i = atoi(buffer) + 1;
sprintf(buffer, "%d ", i);
lseek(fd, 0, SEEK_SET);
write(fd, buffer, strlen(buffer));
fl.l_type = F_UNLCK;
fcntl(fd, F_SETLKW, &fl);

count++;
}

close(fd);
}

int main() {
FILE *file = fopen("example.txt", "w");
fputc('0', file);
fclose(file);

int pid = fork();

if (pid == -1) {
printf("Failed to create child process\n");
} else if (pid == 0) {
code();
printf("Child finished\n");
} else {
code();
printf("Parent finished\n");
}
}

References

Extra

Por diversão, eu experimentei replicar o mesmo problema em outras linguagens.

package main

import (
"fmt"
"os"
"os/exec"
)

func code() {
var count int = 0
file, _ := os.OpenFile("example.txt", os.O_RDWR, 0666)

for count < 10000 {
var i int

file.Seek(0, 0)
fmt.Fscanf(file, "%d", &i)
file.Seek(0, 0)
fmt.Fprintf(file, "%d ", i+1)

count++
}

file.Close()
}

func main() {
if len(os.Args) >= 2 {
code()
fmt.Println("Child finished")
} else {
os.WriteFile("example.txt", []byte{'0'}, 0600)

command := exec.Command("/usr/bin/go", "run", "main.go", "fork")
command.Start()
code()
command.Wait()

fmt.Println("Parent finished")
}
}