Trabalhando com Stream no C#

C# 27 de Jul de 2025

Neste artigo, a gente vai falar sobre Streams no C#. Se você já precisou abrir um arquivo grandão ou mandar dados pela rede, mesmo sem saber, provavelmente já esbarrou com algum tipo de stream. Bora ver alguns detalhes!

Streams

No C#, um Stream é uma forma de ler ou escrever dados em partes, ao invés de fazer tudo de uma vez. Isso é importante quando lidamos com arquivos grandes, pois conseguimos fazer parte por parte ao invés de trabalhar com o arquivo inteiro. Imagine que um stream é um rio e os dados é a água que percorre o rio.

A classe stream no C# é abstrata e fornece métodos para lidar com bytes. Temos classes que lidam com tipos de fontes diferentes que herdam de stream para fornecer as funcionalidades necessárias como: MemoryStream, NetworkStream, etc.

Hierarquia de Streams no .NET

Estrutura das principais classes de stream:

System.Object
│
└── System.MarshalByRefObject
    │
    └── System.IO.Stream (classe base abstrata)
        ├── FileStream
        ├── MemoryStream
        ├── NetworkStream
        ├── CryptoStream
        ├── GZipStream
        ├── DeflateStream
        ├── BufferedStream
        └── PipeStream (usado com NamedPipes)

Leitores e Escritores de stream:

System.Object
│
├── System.IO.TextReader (abstrata)
│   └── StreamReader
│
├── System.IO.TextWriter (abstrata)
│   └── StreamWriter

├── System.IO.BinaryReader
└── System.IO.BinaryWriter

Cursor e Buffer

O que são?

  • Buffer: é um espaço temporário na memória que armazena parte dos dados que estão chegando pelo fluxo(stream). Na explicação acima, onde comparamos que o stream é um rio e a água é os dados, o buffer seria um balde para pegar a água.
  • Cursor: é a posição atual dentro do fluxo(stream), informando onde a leitura ou escrita está acontecendo. A posição do cursor (ou ponteiro) é sempre movida à medida que os dados são lidos ou escritos.

Exemplo prático

class Program
{
	static void Main()
	{
		// Criando um arquivo para demonstrar a manipulação do cursor e buffer
		string caminhoArquivo = "exemplo.txt";

		// Vamos garantir que o arquivo está limpo antes de começar
		if (File.Exists(caminhoArquivo))
			File.Delete(caminhoArquivo);

		// Criando um FileStream para escrever dados no arquivo
		using (FileStream fs = new FileStream(caminhoArquivo, FileMode.Create, FileAccess.Write))
		{
			Console.WriteLine("Escrevendo dados no arquivo...");
			byte[] dados = new byte[] { 65, 66, 67, 68, 69 }; // Representa A, B, C, D, E
			fs.Write(dados, 0, dados.Length);  // Escreve os dados no arquivo

			Console.WriteLine("Posição do cursor após escrita: " + fs.Position);  // Posição após a escrita
		}

		// Agora vamos ler os dados do arquivo e ver como a posição do cursor muda
		using (FileStream fs = new FileStream(caminhoArquivo, FileMode.Open, FileAccess.Read))
		{
			Console.WriteLine("\\nLendo dados do arquivo...");
			byte[] buffer = new byte[2]; // Buffer de 2 bytes
			int bytesLidos;

			// Lendo em blocos de 2 bytes
			while ((bytesLidos = fs.Read(buffer, 0, buffer.Length)) > 0)
			{
				Console.WriteLine($"Lido: {BitConverter.ToString(buffer, 0, bytesLidos)}");
				Console.WriteLine("Posição do cursor após leitura: " + fs.Position); // Posição após cada leitura
			}
		}

		// Usando Seek para mover o cursor manualmente
		using (FileStream fs = new FileStream(caminhoArquivo, FileMode.Open, FileAccess.Read))
		{
			// Movendo o cursor para a posição inicial
			fs.Seek(0, SeekOrigin.Begin);
			Console.WriteLine("\\nApós Seek, posição do cursor: " + fs.Position);

			// Lendo os primeiros 3 bytes (A, B, C)
			byte[] buffer = new byte[3];
			fs.Read(buffer, 0, buffer.Length);
			Console.WriteLine("Dados lidos após Seek: " + BitConverter.ToString(buffer));

			// Movendo o cursor para a posição 2
			fs.Seek(2, SeekOrigin.Begin);
			Console.WriteLine("Após Seek(2), posição do cursor: " + fs.Position);

			// Lendo o byte na posição 2 (C)
			byte[] buffer2 = new byte[1];
			fs.Read(buffer2, 0, buffer2.Length);
			Console.WriteLine("Dados lidos após Seek(2): " + BitConverter.ToString(buffer2));
		}
	}
}

Resultado do console:

Escrevendo dados no arquivo...
Posição do cursor após escrita: 5

Lendo dados do arquivo...
Lido: 41-42
Posição do cursor após leitura: 2
Lido: 43-44
Posição do cursor após leitura: 4
Lido: 45
Posição do cursor após leitura: 5

Após Seek, posição do cursor: 0
Dados lidos após Seek: 41-42-43
Após Seek(2), posição do cursor: 2
Dados lidos após Seek(2): 43

OBS: os valores lidos são retornados em HEX.

Veja que a classe abstrata Stream nos fornece um controle mais refinado sobre os dados que chegam pelo fluxo, podendo ler e escrever na posição que deseja. Se tratando de strings temos o FileStream que traz esse controle maior, mas caso queiramos algo mais abstraído, temos as classes StreamReader/Writer que facilita o trabalho com strings.

Veja a simplificação:

class Program
{
	static void Main()
	{
		// Caminho do arquivo
		string caminhoArquivo = "exemplo.txt";

		// Garantindo que o arquivo está limpo antes de começar
		if (File.Exists(caminhoArquivo))
			File.Delete(caminhoArquivo);

		// Criando um StreamWriter para escrever dados no arquivo
		using (StreamWriter writer = new StreamWriter(caminhoArquivo))
		{
			Console.WriteLine("Escrevendo dados no arquivo...");
			writer.WriteLine("A");
			writer.WriteLine("B");
			writer.WriteLine("C");
			writer.WriteLine("D");
			writer.WriteLine("E");

			// Não precisamos mais gerenciar a posição do cursor
			// StreamWriter já cuida disso automaticamente
		}

		// Agora vamos ler os dados do arquivo usando StreamReader
		using (StreamReader reader = new StreamReader(caminhoArquivo))
		{
			Console.WriteLine("\\nLendo dados do arquivo...");

			string linha;
			while ((linha = reader.ReadLine()) != null)
			{
				Console.WriteLine($"Lido: {linha}");
			}
		}

	}
}

Quando usar cada um?

Vale ressaltar que pelo próprio nome, o FileStream fica explicito que ele trabalha com arquivos(binários), podendo ser imagens, vídeos, arquivos, texto, etc. Já a classe StreamReader e Writer é especializa para trabalhar com texto.

Boas práticas

Todas as classes de Stream trabalham diretamente com bytes e também herdam o IDisposable.

Para gravar uma string, é necessário convertê-la antes com Encoding.UTF8.GetBytes():

using System;
using System.Text;
using System.IO;
using System.Threading;

namespace StreamsAndAsync {
  public class Program {
    static void Main(string[] args) {
      string testMessage = "Testing writing some arbitrary string to a stream";
      byte[] messageBytes = Encoding.UTF8.GetBytes(testMessage);

      using (Stream ioStream = new FileStream(@"stream_demo_file.txt", FileMode.OpenOrCreate)) {
        if (ioStream.CanWrite) {
          ioStream.Write(messageBytes, 0, messageBytes.Length);
        } else {
          Console.WriteLine("Couldn't write to our data stream.");
        }
      }

      Console.WriteLine("Done!");
    }
  }
}

  • Sempre utilize using para garantir o fechamento adequado do fluxo e liberação de recursos não gerenciados.
  • Valide as propriedades do Stream (como CanWrite ou CanRead) antes de realizar operações.

Comparativo entre tipos de Stream

Classe Tipo de Dados Direção Usado com Contexto Comum
FileStream Binário (bytes) Leitura/Escrita Arquivos Manipulação de arquivos em baixo nível
StreamReader Texto (caracteres) Leitura Envolve um Stream Leitura de arquivos de texto
StreamWriter Texto (caracteres) Escrita Envolve um Stream Escrita de arquivos de texto
MemoryStream Binário (bytes) Leitura/Escrita Memória RAM Manipulação temporária de dados em memória
NetworkStream Binário (bytes) Leitura/Escrita Sockets (TcpClient, TcpListener) Comunicação pela rede
GZipStream Binário (bytes) compactado Leitura/Escrita Envolve outro Stream Compressão e descompressão de dados

Mostrando o real valor de usar Stream

Vamos realizar uma operação de buscar, em um arquivo de log com o tamanho de 510 MB, a palavra “erro”. Deixarei um script em python para gerar o arquivo de log:

import random
import os

def generate_large_log_file_with_error(filename, size_in_gb):
    size_in_bytes = size_in_gb * 1024 * 1024 * 500
    halfway_size = size_in_bytes // 2 
    words = ["error", "warning", "info", "critical", "system", "failure", "success", "timeout", "load", "connection"]
    
    with open(filename, 'w') as log_file:
        while os.path.getsize(filename) < size_in_bytes:
            line = " ".join(random.choices(words, k=random.randint(5, 15)))
            
            if os.path.getsize(filename) >= halfway_size:
                line = line.replace(random.choice(words), "erro", 1)
            
            log_file.write(line + '\\n')

generate_large_log_file_with_error('log.txt', 1)

Abaixo temos dois métodos para ler esse arquivo e procurar pela palavra “erro”, um que usará o stream e o outro carregara o arquivo todo em memória. Devo dizer que o desempenho irá variar de acordo com o tamanho do arquivo e esse código é para fins de demonstração dos benefícios de usar o stream apenas:

[MemoryDiagnoser]
[RankColumn]
[SimpleJob(targetCount: 100)]
public class CaseStream
{
    public string CaminhoArquivo { get; set; } = "caminho_do_arquivo";

    [Benchmark]
    public async Task LerArquivosStreamAsync()
    {
        try
        {
            using FileStream fs = new(CaminhoArquivo, FileMode.Open, FileAccess.Read);
            using BufferedStream bs = new(fs, 1024 * 1024 * 2);
            using StreamReader sr = new(bs);
            string linha;

            while ((linha = await sr.ReadLineAsync()) != null)
            {
                var span = linha.AsSpan();

                if (span.IndexOf("Erro") >= 0)
                {
                    Console.WriteLine($"Palavra 'Erro' encontrada: {linha}");
                    break;
                }
            }

            if (linha == null)
            {
                Console.WriteLine("A palavra 'Erro' não foi encontrada no arquivo.");
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine("Erro ao ler o arquivo: " + ex.Message);
        }
    }

    [Benchmark]
    public async Task LerArquivoInteiroAsync()
    {
        try
        {
            string conteudo = await File.ReadAllTextAsync(CaminhoArquivo);

            if (conteudo.Contains("Erro"))
            {
                Console.WriteLine("A palavra 'Erro' foi encontrada no arquivo.");
            }
            else
            {
                Console.WriteLine("A palavra 'Erro' não foi encontrada.");
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine("Erro ao ler o arquivo: " + ex.Message);
        }
    }
}

Em cada cenário haverá uma forma melhor para realizar essa operação e recomendo que no seu cenário, avalie e teste diferentes implementações. Abaixo está o resultado usando o Benchmark.NET para medir:

Method Mean Error StdDev Rank Gen0 Gen1 Gen2 Allocated
LerArquivosStreamAsync 436.0 ms 3.86 ms 10.94 ms 1 101000.0000 - - 1.58 GB
LerArquivoInteiroAsync 1,859.0 ms 43.18 ms 127.31 ms 2 86000.0000 85000.0000 6000.0000 1.98 GB

Vemos que no geral, o stream se sobressai em relação a segunda implementação. Alguns pontos importantes é se atentar ao tamanho do buffer que definimos, por padrão o buffer usado é de 1KB no FileStream e no BufferedStream é 4KB e isso impacta no Garbage Collection por ter que realizar as limpezas desses buffers, pois quanto menor, mais limpezas ocorrerão.

Conclusão

Com isso, vemos que o Stream é um grande aliado quando necessitamos de trabalhar com arquivos grandes, pois ele vai fazendo o serviço aos poucos sem travar a thread e também na maioria dos casos, economizará memória da sua aplicação.

Marcadores