diff --git a/src/highlight.rs b/src/highlight.rs
@@ -38,8 +38,9 @@ static SYNTAXES: OnceLock<SyntaxSet> = OnceLock::new();
pub struct Highlighter {
syntaxes: &'static SyntaxSet,
/// 파싱된 펜스 옵션을 `write_code_tag` → `write_highlighted` 사이에 건네는
- /// 보관함. comrak은 한 블록을 pre → code → highlighted 순서로 부르고 렌더는
- /// 단일 스레드다. 병렬 렌더를 도입하면 이 상태는 렌더 단위별로 분리해야 한다.
+ /// 보관함. comrak은 한 블록을 pre → code → highlighted 순서로 부르고, 한
+ /// 페이지의 렌더는 단일 스레드다. 페이지들은 병렬로 렌더되므로 인스턴스는
+ /// **호출(페이지)마다 새로 만든다** — markdown.rs의 render_in이 그렇게 한다.
pending: Mutex<FenceOpts>,
/// 잘못된 펜스 옵션. 렌더가 끝난 뒤 빌드를 실패시키는 데 쓴다 — 모르는 키를
/// 조용히 무시하면 `hl_line=` 오타가 강조 없는 채로 배포된다.
diff --git a/src/lib.rs b/src/lib.rs
@@ -240,8 +240,13 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
// 언어 코드 → 그 언어의 검색 색인 행들.
let mut index: BTreeMap<String, Vec<search::Entry>> = BTreeMap::new();
- for page in &pages {
- let (html, entry) = render_page(
+ // 페이지 렌더는 서로 완전히 독립이라 병렬로 돌린다. 결정성은 스케줄이 아니라
+ // 병합이 정한다: 각 결과를 페이지 인덱스 자리에 꽂고 입력 순서대로 합치므로,
+ // 출력 바이트도 — 실패할 때 어느 에러가 보이는지도 — 스레드 수와 무관하다.
+ // 분배는 원자 카운터 훔치기다. 코드 블록이 많은 페이지와 없는 페이지의 렌더
+ // 비용 차가 커서, 미리 나누는 청크 분배는 가장 느린 청크를 기다리게 된다.
+ let render_one = |page: &Page| {
+ render_page(
page,
&site,
&cfg,
@@ -256,7 +261,40 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
.contains_key(&page.language)
.then(|| format!("{}/{}", cfg.base_path(), feed::path(&page.language)))
.as_deref(),
- )?;
+ )
+ };
+ let workers = std::thread::available_parallelism()
+ .map(|n| n.get())
+ .unwrap_or(1)
+ .min(pages.len())
+ .max(1);
+ let mut results: Vec<Option<Result<RenderedPage>>> =
+ std::iter::repeat_with(|| None).take(pages.len()).collect();
+ let next = std::sync::atomic::AtomicUsize::new(0);
+ let done = std::thread::scope(|scope| {
+ let handles: Vec<_> = (0..workers)
+ .map(|_| {
+ scope.spawn(|| {
+ let mut local = Vec::new();
+ loop {
+ let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let Some(page) = pages.get(i) else { break };
+ local.push((i, render_one(page)));
+ }
+ local
+ })
+ })
+ .collect();
+ handles
+ .into_iter()
+ .flat_map(|h| h.join().expect("렌더 스레드가 패닉했다"))
+ .collect::<Vec<_>>()
+ });
+ for (i, r) in done {
+ results[i] = Some(r);
+ }
+ for (page, result) in pages.iter().zip(results) {
+ let (html, entry) = result.expect("모든 페이지가 렌더된다")?;
out.files.insert(
page.out_path.to_string_lossy().replace('\\', "/"),
html.into_bytes(),
@@ -423,6 +461,9 @@ fn check_output_collisions(out: &BuildOutput) -> Result<()> {
/// 페이지 HTML과 그 페이지의 검색 색인 행을 함께 낸다. 본문 평문은 렌더 과정에서
/// 이미 나오므로, 색인을 위해 문서를 다시 파싱하지 않는다. 색인을 끈 사이트에는
/// 행을 만들지 않는다 — 페이지당 본문 사본 하나가 통째로 사라진다.
+/// 페이지 하나의 렌더 결과: HTML과, 검색이 켜져 있을 때의 색인 행.
+type RenderedPage = (String, Option<search::Entry>);
+
// 렌더 루프 한 곳에서만 부르는 내부 함수라 인자를 묶는 구조체가 이름값을 못 한다.
#[allow(clippy::too_many_arguments)]
fn render_page(
@@ -434,7 +475,7 @@ fn render_page(
sections: minijinja::Value,
highlight_css: Option<&str>,
feed_url: Option<&str>,
-) -> Result<(String, Option<search::Entry>)> {
+) -> Result<RenderedPage> {
let template = select_template(page, site, templates).map_err(Kind::Template.tag())?;
let site_ctx = SiteCtx {
diff --git a/src/markdown.rs b/src/markdown.rs
@@ -126,6 +126,14 @@ impl Renderer {
options.extension.image_url_rewriter = Some(r.clone());
}
+ // 하이라이터도 호출마다 새로 만든다. pending(펜스 옵션 핸드오프)과
+ // errors가 페이지별 상태라, 인스턴스를 페이지끼리 공유하면 병렬 렌더에서
+ // 서로의 옵션과 에러가 섞인다. SyntaxSet은 정적 공유라 비용은 Mutex 두 개다.
+ let highlighter = self
+ .highlighter
+ .as_ref()
+ .map(|_| crate::highlight::Highlighter::new());
+
// 한 번만 파싱해서 HTML과 평문을 같은 트리에서 뽑는다. 링크 재작성기는
// 렌더 단계에서 도므로 평문 수집이 그 결과에 영향을 받지 않는다.
let arena = Arena::new();
@@ -135,7 +143,7 @@ impl Renderer {
let html = {
let mut plugins = Plugins::default();
plugins.render.heading_adapter = Some(&headings);
- if let Some(h) = &self.highlighter {
+ if let Some(h) = &highlighter {
plugins.render.codefence_syntax_highlighter = Some(h);
}
let mut out = String::new();
@@ -148,8 +156,7 @@ impl Renderer {
.as_ref()
.map(|r| r.take_unresolved())
.unwrap_or_default();
- let bad_fences = self
- .highlighter
+ let bad_fences = highlighter
.as_ref()
.map(|h| h.take_errors())
.unwrap_or_default();
@@ -469,6 +476,32 @@ mod tests {
assert!(r.text.contains("Some bold and a link."), "실제: {}", r.text);
}
+ #[test]
+ fn concurrent_renders_do_not_share_state() {
+ // 하이라이터의 pending/errors가 호출 간에 공유되면 병렬 렌더에서 다른
+ // 페이지의 펜스 옵션과 에러가 서로 섞인다. 단일 스레드 결과를 기준으로
+ // 여러 스레드가 반복 렌더해도 같은 결과인지 본다.
+ let r = Renderer::new(&MarkdownConfig::default())
+ .with_highlighter(crate::highlight::Highlighter::new());
+ let a = "```rust hl_lines=1 name=a.rs\nlet a = 1;\nlet b = 2;\n```";
+ let b = "```rust linenos=oops\nlet c = 3;\n```";
+ let expect_a = r.render(a).html;
+ std::thread::scope(|s| {
+ for _ in 0..8 {
+ s.spawn(|| {
+ for _ in 0..50 {
+ let ra = r.render(a);
+ assert_eq!(ra.html, expect_a);
+ assert!(ra.bad_fences.is_empty(), "실제: {:?}", ra.bad_fences);
+ let rb = r.render(b);
+ assert_eq!(rb.bad_fences.len(), 1, "실제: {:?}", rb.bad_fences);
+ assert!(!rb.html.contains("data-name"), "옵션이 샜다: {}", rb.html);
+ }
+ });
+ }
+ });
+ }
+
#[test]
fn anchors_can_be_disabled_but_ids_remain() {
let cfg = MarkdownConfig {
|