C# and C++ Interoperability Development Series (Part 28): A Comprehensive Guide to Zero-Copy Technology with Three Efficient Solutions

C# and C++ Interoperability Development Series (Part 28): A Comprehensive Guide to Zero-Copy Technology with Three Efficient Solutions

Introduction

In previous blog posts, we explored many practical techniques for calling C++ from C#. Today, we will continue to delve into “Zero-Copy Technology.” What is “Zero-Copy,” why is it crucial in high-performance scenarios, and how to correctly implement it during C# and C++ interoperability to avoid unnecessary data copying, maximize throughput, and reduce latency.

1. Why Focus on Zero-Copy?

In C# and C++ cross-language interactions, data often crosses the boundaries of managed memory and unmanaged memory. By default, this process may involve multiple data copies, such as:

  1. 1. Device driver → C++ buffer (native)
  2. 2. C++ buffer → Intermediate buffer (native)
  3. 3. Intermediate buffer → Managed array (C#)

If the data volume is large (e.g., video frames, ECG waveforms, Lidar point clouds, industrial images), an additional copy can lead to increased latency and CPU overhead.The goal of zero-copy is to—allow data to be read directly by the consumer after being generated by the producer, without intermediate data copying.

2. What is Zero-Copy?

In traditional data transfer processes (such as reading files, network send/receive), data is copied multiple times between kernel mode and user mode:

C# and C++ Interoperability Development Series (Part 28): A Comprehensive Guide to Zero-Copy Technology with Three Efficient Solutions

This leads to the following issues:

  • Multiple memory copies (CPU involvement), increasing latency and CPU load
  • • Significant performance bottlenecks in processing large data streams (video, audio, ECG signals, etc.)

The goal of zero-copy:

To avoid redundant memory copies between data producers (files, networks, sensors) and consumers (business logic, rendering, processing algorithms), directly sharing the data storage area.

3. Core Ideas of Zero-Copy

Core Principles:

  • Share the same physical memory (avoid duplicate allocation and copying)
  • Directly pass pointers/references (instead of content copying)
  • Clear data lifecycle (who allocates, who releases, when it can be modified)

4. Common Zero-Copy Implementation Methods (Cross C# and C++)

C# and C++ Interoperability Development Series (Part 28): A Comprehensive Guide to Zero-Copy Technology with Three Efficient Solutions
Method Description Advantages Disadvantages
Pinned Memory C# allocates <span>byte[]</span> and uses <span>GCHandle.Alloc(..., Pinned)</span> to pin the address, passing it to C++ for writing No need to copy to the managed side High GC pressure (pinned memory can block GC compaction)
Unmanaged Allocation + IntPtr Encapsulation C++ allocates memory, C# accesses it directly using <span>IntPtr</span>/<span>Span<byte></span> Does not occupy the managed heap, lifecycle controlled by C++ Requires manual release
MemoryMappedFile Cross-process sharing of large memory blocks Efficient across processes High complexity in usage, especially synchronization
Unsafe Pointer + Span C# <span>unsafe</span> directly manipulates native pointers Highest performance Loss of managed safety, strict lifecycle management required
C++/CLI Managed Pointer Bridging Directly maps native buffers to managed-accessible objects using C++/CLI Better safety, simple encapsulation Requires C++/CLI compilation support

5. The Three Most Common Cross-Language Zero-Copy Patterns

Pattern 1 — C# Allocation + Pinned Memory

C# allocates a buffer and <span>pins</span> it, passing it to C++ to fill data:

using System;
using System.Runtime.InteropServices;

class ZeroCopyPinned
{
    [DllImport("ZeroCopyDLL.dll", CallingConvention = CallingConvention.Cdecl)]
    public static extern void FillBuffer(IntPtr buf, int length);

    public static void Main()
    {
        byte[] buffer = new byte[1024 * 1024]; // 1MB
        var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);

        try
        {
            FillBuffer(handle.AddrOfPinnedObject(), buffer.Length);
            Console.WriteLine($"First byte: {buffer[0]}");
        }
        finally
        {
            handle.Free();
        }
    }
}

C++ Implementation:

extern "C" __declspec(dllexport)
void FillBuffer(unsigned char* buf, int length)
{
    for (int i = 0; i < length; ++i) buf[i] = (unsigned char)(i & 0xFF);
}

Characteristics:

  • • Completely avoids the extra copy of <span>Marshal.Copy</span>
  • • Buffer is on the managed heap, but the address is pinned, so GC will not move it
C# and C++ Interoperability Development Series (Part 28): A Comprehensive Guide to Zero-Copy Technology with Three Efficient Solutions

Pattern 2 — C++ Allocation + C# Using IntPtr

Let C++ allocate a block of memory, and C# directly use <span>IntPtr</span> or <span>Span<byte></span> to operate:

C++:

#include <cstdlib>
#include <cstring>

extern "C" __declspec(dllexport)
unsigned char* AllocateBuffer(int length)
{
    unsigned char* buf = (unsigned char*)std::malloc(length);
    std::memset(buf, 0xAB, length);
    return buf;
}

extern "C" __declspec(dllexport)
void FreeBuffer(unsigned char* buf)
{
    std::free(buf);
}

C#:

using System;
using System.Runtime.InteropServices;

class ZeroCopyNativeAlloc
{
    [DllImport("ZeroCopyDLL.dll", CallingConvention = CallingConvention.Cdecl)]
    public static extern IntPtr AllocateBuffer(int length);

    [DllImport("ZeroCopyDLL.dll", CallingConvention = CallingConvention.Cdecl)]
    public static extern void FreeBuffer(IntPtr ptr);

    public static void Main()
    {
        IntPtr ptr = AllocateBuffer(1024);
        try
        {
            unsafe
            {
                byte* p = (byte*)ptr;
                Console.WriteLine($"First byte: {p[0]:X2}");
            }
        }
        finally
        {
            FreeBuffer(ptr);
        }
    }
}

Characteristics:

  • • Data is not in the managed heap, completely controlled by native
  • • Suitable for large data buffers, avoiding GC pressure
  • • Must be careful to release, or memory leaks will occur
    C# and C++ Interoperability Development Series (Part 28): A Comprehensive Guide to Zero-Copy Technology with Three Efficient Solutions
    Insert image description here

Pattern 3 — Cross-Process Implementation (MemoryMappedFile)

If C# and C++ are in different processes, you can also use shared memory (MemoryMappedFile):

  • • C++ uses <span>CreateFileMapping</span> / <span>MapViewOfFile</span>
  • • C# uses <span>MemoryMappedFile.OpenExisting</span> + <span>CreateViewAccessor</span>
  • • Shares the same physical page, read and write without copying
  • • Commonly used for video capture, industrial image processing, and shared ring buffers
    C# and C++ Interoperability Development Series (Part 28): A Comprehensive Guide to Zero-Copy Technology with Three Efficient Solutions
  • • After mapping, C#, C++, and even multiple processes access the same physical memory (the operating system completes this through virtual memory mapping)
  • • Therefore, data does not need to be copied to user buffers
  • • Read and write performance is close to direct memory access (as long as page faults are not triggered, performance is extremely high)

C++ Writer (Producer):

// MMF_Producer.cpp
#include <windows.h>
#include <iostream>
#include <string>

const WCHAR* MMF_NAME = L"MySharedMemory";
const int BUFFER_SIZE = 256;

int main()
{
    HANDLE hMapFile = CreateFileMapping(
        INVALID_HANDLE_VALUE,    // Use paging file
        NULL,                    // Default security
        PAGE_READWRITE,          // Read/write access
        0,                       // Maximum object size (high-order DWORD)
        BUFFER_SIZE,             // Maximum object size (low-order DWORD)
        MMF_NAME);               // Name of mapping object

    if (hMapFile == NULL)
    {
        std::cerr << "Could not create file mapping object (" << GetLastError() << ").\n";
        return 1;
    }

    char* pBuf = (char*)MapViewOfFile(
        hMapFile,                // Handle to map object
        FILE_MAP_ALL_ACCESS,     // Read/write access
        0,                       // High-order DWORD of the file offset
        0,                       // Low-order DWORD of the file offset
        BUFFER_SIZE);            // Number of bytes to map

    if (pBuf == NULL)
    {
        std::cerr << "Could not map view of file (" << GetLastError() << ").\n";
        CloseHandle(hMapFile);
        return 1;
    }

    std::string message = "Hello from C++!";
    strncpy_s(pBuf, BUFFER_SIZE, message.c_str(), message.length());
    std::cout << "Data written to shared memory: " << message << std::endl;

    // Keep the mapping open for C# to read
    std::cout << "Press Enter to exit...";
    std::cin.getline(pBuf, 1);

    UnmapViewOfFile(pBuf);
    CloseHandle(hMapFile);

    return 0;
}

C# Reader (Consumer):

// MMF_Consumer.cs
using System;
using System.IO.MemoryMappedFiles;
using System.Text;
using System.Threading;

class MMFConsumer
{
    const string MMF_NAME = "MySharedMemory";
    const int BUFFER_SIZE = 256;

    static void Main(string[] args)
    {
        try
        {
            using (var mmf = MemoryMappedFile.OpenExisting(MMF_NAME))
            {
                using (var accessor = mmf.CreateViewAccessor(0, BUFFER_SIZE))
                {
                    byte[] buffer = new byte[BUFFER_SIZE];
                    accessor.ReadArray(0, buffer, 0, buffer.Length);
                    string message = Encoding.ASCII.GetString(buffer).TrimEnd('?').TrimEnd('\0');
                    Console.WriteLine($"Data read from shared memory: {message}");
                }
            }
        }
        catch (FileNotFoundException)
        {
            Console.WriteLine("Memory mapped file not found. Make sure C++ producer is running.");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"An error occurred: {ex.Message}");
        }

        Console.WriteLine("Press any key to exit...");
        Console.ReadKey();
    }
}
C# and C++ Interoperability Development Series (Part 28): A Comprehensive Guide to Zero-Copy Technology with Three Efficient Solutions

6. Considerations When Implementing Zero-Copy

  1. 1. Lifecycle Management — Ensure that C++ does not release the memory while C# is accessing it.
  2. 2. Thread Synchronization — Although there is no copying, read and write may still conflict, requiring locks or lock-free synchronization strategies (e.g., double buffering, atomic flags).
  3. 3. Alignment and Cache Lines — Large data blocks should consider memory alignment (16/32/64 bytes) to prevent false sharing.
  4. 4. GC Pressure — Frequent <span>pin</span> can lead to fragmentation of the managed heap, and long-term pinning of large objects can hinder GC compaction.
  5. 5. Safety — Direct manipulation of native pointers may cause C# crashes; it is recommended to add boundary checks during development.

7. Conclusion

  • Zero-Copy is not “completely no data transfer”, but rather avoiding unnecessary memory copying.
  • • In C# and C++ interoperability, the most commonly used zero-copy methods are:
  1. 1. C# pinned managed memory passed to C++ (GCHandle / fixed)
  2. 2. C++ allocated memory directly passed to C# (IntPtr / Span)
  • • In high-performance scenarios, it can also be combined with double buffering and ring buffers to achieve lock-free high throughput.
  • If this article has been helpful to you, I would be very honored.

    If you have other opinions on this article, feel free to leave a comment for discussion.

    If you like my articles, thank you for the triple support: like, follow, and share!!!

    Leave a Comment