C++ 고급 문법 (12) — cout과 endl

로그를 백만 줄 쓰는 코드에서 std::endl을 '\n'으로 바꾸기만 했는데 실행 시간이 수십 배 줄어든다. std::endl은 줄바꿈과 함께 버퍼를 비우는(flush) 조작자이고, flush는 매번 시스템 호출로 이어진다. std::cout에는 이 밖에도 C stdio와의 동기화, std::cin과의 연결(tie), 한 번 설정하면 계속 유지되는 서식 상태 같은 숨은 동작이 있다. 이 글에서는 g++ 13.3과 strace로 cout의 버퍼링 동작을 확인하고, 조작자, 사용자 타입 출력, C++20 std::format까지 정리한다.

이 시리즈의 다른 글

endl과 ‘\n’

#include <chrono>
#include <cstdio>
#include <cstring>
#include <fstream>

int main(int argc, char** argv)
{
    bool use_endl = argc > 1 && std::strcmp(argv[1], "endl") == 0;
    std::ofstream out("out.txt");
    auto t0 = std::chrono::steady_clock::now();
    for (int i = 0; i < 1'000'000; ++i) {
        if (use_endl) out << i << std::endl;   // '\n' + flush
        else          out << i << '\n';
    }
    out.close();
    auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
    std::printf("%-5s %8.1f ms\n", use_endl ? "endl" : "\\n", ms);
}
$ g++ -std=c++20 -Wall -O2 -o endl endl.cpp
$ ./endl nl
\n        28.2 ms
$ ./endl endl
endl     740.0 ms
$ strace -c -e trace=write,writev ./endl nl 2>&1 | grep -E 'calls|write'
% time     seconds  usecs/call     calls    errors syscall
 99.78    0.008052           9       840           writev
  0.22    0.000018           9         2           write
$ strace -c -e trace=write,writev ./endl endl 2>&1 | grep -E 'calls|write'
% time     seconds  usecs/call     calls    errors syscall
100.00    9.453643           9   1000001           write

'\n'은 버퍼가 찰 때만 writev로 내보내 시스템 호출이 842번이고, std::endl은 줄마다 write를 불러 100만 번이다. 즉시 보여야 하는 출력이 아니면 '\n'을 쓰고, 필요한 지점에서만 std::flush를 부른다.

sync_with_stdio

기본적으로 cout은 C의 stdout과 출력 순서를 맞추도록 동기화되어 있다. 끄면 cout이 자체 버퍼를 쓰게 되어 printf와 순서가 뒤섞인다.

#include <cstdio>
#include <iostream>

int main()
{
    std::ios::sync_with_stdio(false);   // C stdio와 버퍼 공유를 끈다 (빨라지지만 순서 보장이 사라짐)
    std::cout << "1: cout\n";
    std::printf("2: printf\n");
    std::cout << "3: cout\n";
    std::printf("4: printf\n");
}
$ g++ -std=c++20 -Wall -o sy sync.cpp
$ ./sy
1: cout
3: cout
2: printf
4: printf
$ ./sy | cat
1: cout
3: cout
2: printf
4: printf
#include <chrono>
#include <cstdio>
#include <cstring>
#include <iostream>

int main(int argc, char** argv)
{
    bool nosync = argc > 1 && std::strcmp(argv[1], "nosync") == 0;
    if (nosync) std::ios::sync_with_stdio(false);
    auto t0 = std::chrono::steady_clock::now();
    for (int i = 0; i < 5'000'000; ++i) std::cout << i << '\n';
    auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
    std::fprintf(stderr, "%-7s %7.1f ms\n", nosync ? "nosync" : "sync", ms);
}
$ g++ -std=c++20 -Wall -O2 -o sb syncbench.cpp
$ ./sb > /dev/null
sync      146.6 ms
$ ./sb nosync > /dev/null
nosync    118.2 ms

이 환경에서 500만 줄 기준 차이는 15~25% 정도였다(여러 번 실행해 135ms 대 115ms 안팎). 순서가 섞여도 되는 경우, 즉 printf와 섞어 쓰지 않을 때만 끈다.

cin.tie

#include <iostream>
#include <string>

int main(int argc, char**)
{
    if (argc > 1) std::cin.tie(nullptr);     // cout과 cin의 연결을 끊는다
    std::cout << "name? ";                   // 개행도 flush도 없다
    std::string name;
    std::cin >> name;                        // 기본값: 읽기 전에 cout을 flush
    std::cout << "hi " << name << '\n';
}
$ g++ -std=c++20 -Wall -o tie tie.cpp
$ echo kim | strace -e trace=read,write ./tie 2>&1 >/dev/null | grep -E '^(read\(0|write\(1)'
write(1, "name? ", 6)                   = 6
read(0, "kim\n", 4096)                  = 4
write(1, "hi kim\n", 7)                 = 7
$ echo kim | strace -e trace=read,write ./tie untie 2>&1 >/dev/null | grep -E '^(read\(0|write\(1)'
read(0, "kim\n", 4096)                  = 4
write(1, "name? hi kim\n", 13)          = 13

기본 상태에서는 cin이 읽기 전에 cout을 flush해서 프롬프트가 먼저 나간다. tie(nullptr)로 끊으면 프롬프트가 입력 이후로 밀린다.

스트림대상버퍼링tie
std::coutstdout버퍼링없음
std::cinstdin버퍼링cout
std::cerrstderrunitbuf (출력마다 flush)cout
std::clogstderr버퍼링없음

조작자와 서식 상태

#include <iomanip>
#include <iostream>

int main()
{
    double pi = 3.14159265;
    std::cout << std::fixed << std::setprecision(2) << pi << ' ' << 2.0 << '\n';   // 유지됨(sticky)
    std::cout << std::setw(8) << 42 << '|' << 42 << "|\n";                       // setw는 한 번만
    std::cout << std::hex << std::showbase << 255 << ' ' << 16 << '\n';          // hex도 유지됨
    std::cout << 10 << '\n';                                                     // 여전히 16진수
    std::cout << std::dec << std::boolalpha << 10 << ' ' << true << '\n';
}
$ g++ -std=c++20 -Wall -o mp manip.cpp
$ ./mp
3.14 2.00
      42|42|
0xff 0x10
0xa
10 true

setw만 다음 출력 한 번에 적용되고, fixed·setprecision·hex 등은 스트림 상태로 남는다. 함수 안에서 서식을 바꿨다면 copyfmt로 저장했다가 복원한다.

#include <iomanip>
#include <iostream>

void dump_hex(std::ostream& os, unsigned v)
{
    std::ios state(nullptr);
    state.copyfmt(os);                                // 호출자 서식 저장
    os << std::hex << std::setfill('0') << std::setw(8) << v << '\n';
    os.copyfmt(state);                                // 복원
}

int main()
{
    dump_hex(std::cout, 0xBEEF);
    std::cout << 255 << '\n';                         // 10진수로 돌아와 있다
}
$ g++ -std=c++20 -Wall -o rs restore.cpp
$ ./rs
0000beef
255

사용자 타입 출력

#include <iomanip>
#include <iostream>
#include <sstream>

struct Point { double x, y; };

// 스트림 출력: 비멤버, 스트림 참조를 반환해 체이닝
std::ostream& operator<<(std::ostream& os, const Point& p)
{
    return os << '(' << p.x << ", " << p.y << ')';
}

// 사용자 정의 조작자(manipulator): ostream&를 받는 함수
std::ostream& tab(std::ostream& os) { return os << '\t'; }

int main()
{
    Point p{1.5, -2};
    std::cout << "p=" << p << tab << "end\n";

    std::cout << std::setw(12) << p << "|\n";     // setw는 첫 출력 '(' 에만 적용된다

    std::ostringstream ss;                         // 한 번에 만들어 폭 적용
    ss << p;
    std::cout << std::setw(12) << ss.str() << "|\n";
}
$ g++ -std=c++20 -Wall -o cu custom.cpp
$ ./cu
p=(1.5, -2)	end
           (1.5, -2)|
   (1.5, -2)|

operator<< 안에서 여러 번 나눠 출력하므로 setw는 첫 조각 '('에만 걸린다. 폭을 전체에 적용하려면 문자열로 먼저 만든다.

C++20 std::format

#include <format>
#include <iostream>

struct Point { double x, y; };

template <>
struct std::formatter<Point> : std::formatter<std::string> {
    auto format(const Point& p, std::format_context& ctx) const
    {
        return std::formatter<std::string>::format(std::format("({}, {})", p.x, p.y), ctx);
    }
};

int main()
{
    std::cout << std::format("{:>8.2f}|{:#x}|{}\n", 3.14159, 255, true);   // 서식이 호출마다 독립
    std::cout << std::format("[{:>12}]\n", Point{1.5, -2});                  // 폭이 전체에 적용
}
$ g++ -std=c++20 -Wall -o fm fmt.cpp
$ ./fm
    3.14|0xff|true
[   (1.5, -2)]

std::format은 서식이 호출마다 독립적이라 상태가 남지 않고, formatter 특수화로 사용자 타입에도 폭 지정이 그대로 적용된다. 출력까지 한 번에 하는 C++23 std::print는 GCC 14부터 지원한다.

주의사항

상황문제대응
반복문에서 std::endl줄마다 시스템 호출'\n', 필요한 곳만 std::flush
sync_with_stdio(false) 후 printf 혼용출력 순서 뒤섞임한쪽만 사용
cin.tie(nullptr) 후 프롬프트 출력프롬프트가 입력 뒤에 나옴프롬프트 뒤에 std::flush
함수에서 hex·precision 변경호출자 출력 서식이 바뀜copyfmt로 저장·복원, 또는 std::format
크래시 직전 로그버퍼에 남아 사라짐진단 로그는 std::cerr

마무리

  • std::endl은 flush를 포함하므로 백만 줄에서 시스템 호출이 842번에서 100만 번으로 늘고 26배 느려졌다.
  • sync_with_stdio와 tie는 출력 순서를 보장하는 장치이며, 끄면 빨라지는 대신 순서가 바뀐다.
  • 스트림 서식은 상태로 남으므로, 새 코드에서는 std::format이 다루기 쉽다.

참고

답글 남기기