Tại sao tạo ra một Chủ đề được cho là đắt tiền?


180

Các hướng dẫn Java nói rằng việc tạo một Thread rất tốn kém. Nhưng tại sao chính xác là nó đắt? Chính xác thì điều gì đang xảy ra khi một luồng Java được tạo ra khiến cho việc tạo ra nó trở nên đắt đỏ? Tôi đang tuyên bố là đúng, nhưng tôi chỉ quan tâm đến cơ chế tạo luồng trong JVM.

Chủ đề vòng đời trên không. Tạo chủ đề và xé không miễn phí. Chi phí thực tế khác nhau giữa các nền tảng, nhưng việc tạo luồng cần có thời gian, đưa độ trễ vào xử lý yêu cầu và yêu cầu một số hoạt động xử lý của JVM và OS. Nếu các yêu cầu là thường xuyên và nhẹ, như trong hầu hết các ứng dụng máy chủ, việc tạo một luồng mới cho mỗi yêu cầu có thể tiêu tốn tài nguyên tính toán đáng kể.

Từ đồng thời Java trong thực tiễn của
Brian Goetz, Tim Peierls, Joshua Bloch, Joseph Bowbeer, David Holmes, Doug Lea
In ISBN-10: 0-321-34960-1


Tôi không biết bối cảnh trong đó các hướng dẫn bạn đã đọc nói về điều này: chúng có ngụ ý rằng bản thân tác phẩm đó đắt tiền hay "tạo ra một chủ đề" là tốn kém. Sự khác biệt mà tôi cố gắng thể hiện là giữa hành động thuần túy tạo ra luồng (hãy gọi nó là khởi tạo nó hoặc một cái gì đó), hoặc thực tế là bạn có một luồng (vì vậy sử dụng một luồng: rõ ràng là có chi phí). Cái nào được khẳng định // cái nào bạn muốn hỏi về?
Nanne

9
@typoknig - Đắt tiền so với việc không tạo chủ đề mới :)
willcodejavaforfood

có thể trùng lặp chi phí tạo luồng Java
Paul Draper

1
threadpools cho chiến thắng. không cần phải luôn luôn tạo chủ đề mới cho các nhiệm vụ.
Alexander Mills

Câu trả lời:


149

Việc tạo luồng Java rất tốn kém vì có một chút công việc liên quan:

  • Một khối lớn bộ nhớ phải được phân bổ và khởi tạo cho ngăn xếp luồng.
  • Các cuộc gọi hệ thống cần được thực hiện để tạo / đăng ký luồng gốc với HĐH máy chủ.
  • Các mô tả cần được tạo, khởi tạo và thêm vào các cấu trúc dữ liệu bên trong JVM.

Nó cũng đắt tiền theo nghĩa là sợi chỉ liên kết tài nguyên miễn là nó còn sống; ví dụ ngăn xếp luồng, bất kỳ đối tượng nào có thể truy cập từ ngăn xếp, bộ mô tả luồng JVM, bộ mô tả luồng gốc của hệ điều hành.

Chi phí của tất cả những điều này là cụ thể cho nền tảng, nhưng chúng không hề rẻ trên bất kỳ nền tảng Java nào tôi từng gặp.


Một tìm kiếm của Google đã tìm cho tôi một điểm chuẩn cũ báo cáo tốc độ tạo luồng ~ 4000 mỗi giây trên Sun Java 1.4.1 trên bộ xử lý kép cổ điển 2002 Xeon chạy Linux cổ điển 2002. Một nền tảng hiện đại hơn sẽ cho số lượng tốt hơn ... và tôi không thể nhận xét về phương pháp này ... nhưng ít nhất nó mang lại một sân chơi bóng cho việc tạo ra sợi đắt tiền như thế nào .

Điểm chuẩn của Peter Lawrey chỉ ra rằng việc tạo luồng ngày nay nhanh hơn đáng kể về mặt tuyệt đối, nhưng không rõ mức độ cải thiện này trong Java và / hoặc HĐH ... hoặc tốc độ bộ xử lý cao hơn. Nhưng số của anh ta vẫn chỉ ra sự cải thiện gấp 150 lần nếu bạn sử dụng nhóm luồng so với việc tạo / bắt đầu một luồng mới mỗi lần. (Và anh ấy đưa ra quan điểm rằng tất cả chỉ là tương đối ...)


(Ở trên giả định "các luồng gốc" thay vì "các luồng xanh", nhưng các JVM hiện đại đều sử dụng các luồng gốc vì lý do hiệu năng. Các luồng xanh có thể rẻ hơn để tạo, nhưng bạn phải trả tiền cho các khu vực khác.)


Tôi đã thực hiện một chút đào để xem cách ngăn xếp của một luồng Java thực sự được phân bổ. Trong trường hợp OpenJDK 6 trên Linux, ngăn xếp luồng được phân bổ theo lệnh gọi để pthread_createtạo luồng gốc. (JVM không vượt qua pthread_createngăn xếp preallocated.)

Sau đó, trong pthread_createngăn xếp được phân bổ bởi một cuộc gọi mmapnhư sau:

mmap(0, attr.__stacksize, 
     PROT_READ|PROT_WRITE|PROT_EXEC, 
     MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)

Theo đó man mmap, MAP_ANONYMOUScờ làm cho bộ nhớ được khởi tạo về không.

Do đó, mặc dù có thể không cần thiết rằng các ngăn xếp luồng Java mới bằng 0 (theo thông số JVM), trong thực tế (ít nhất là với OpenJDK 6 trên Linux), chúng bằng 0.


2
@Raedwald - đó là phần khởi tạo đắt tiền. Ở đâu đó, một cái gì đó (ví dụ như GC hoặc HĐH) sẽ bằng 0 byte trước khi khối được chuyển thành ngăn xếp luồng. Điều đó có chu kỳ bộ nhớ vật lý trên phần cứng điển hình.
Stephen C

2
"Ở đâu đó, một cái gì đó (ví dụ như GC hoặc HĐH) sẽ bằng 0 byte". Nó sẽ? HĐH sẽ yêu cầu phân bổ trang bộ nhớ mới vì lý do bảo mật. Nhưng điều đó sẽ không phổ biến. Và hệ điều hành có thể giữ một bộ đệm của các trang không có ed (IIRC, Linux làm như vậy). Tại sao GC lại bận tâm, cho rằng JVM sẽ ngăn bất kỳ chương trình Java nào đọc nội dung của nó? Lưu ý rằng malloc()hàm C tiêu chuẩn , mà JVM có thể sử dụng tốt, không đảm bảo rằng bộ nhớ được phân bổ là zero-ed (có lẽ để tránh chỉ các vấn đề về hiệu năng như vậy).
Raedwald

1
stackoverflow.com/questions/2117072/ đá đồng tình rằng "Một yếu tố chính là bộ nhớ ngăn xếp được phân bổ cho mỗi luồng".
Raedwald

2
@Raedwald - xem câu trả lời cập nhật để biết thông tin về cách ngăn xếp thực sự được phân bổ.
Stephen C

2
Có thể (thậm chí có thể) rằng các trang bộ nhớ được phân bổ bởi mmap()cuộc gọi được sao chép trên bản đồ thành một trang không, vì vậy việc khởi tạo của chúng không xảy ra trong mmap()chính nó, nhưng khi các trang được viết lần đầu tiên , và sau đó chỉ có một trang tại một thời gian. Đó là, khi luồng bắt đầu thực thi, với chi phí bourne bởi luồng được tạo chứ không phải luồng của người tạo.
Raedwald

76

Những người khác đã thảo luận về chi phí của luồng đến từ đâu. Câu trả lời này bao gồm lý do tại sao việc tạo ra một luồng không quá đắt so với nhiều thao tác, nhưng tương đối đắt so với các lựa chọn thay thế thực thi nhiệm vụ, tương đối ít tốn kém hơn.

Sự thay thế rõ ràng nhất để chạy một tác vụ trong một luồng khác là chạy tác vụ trong cùng một luồng. Điều này rất khó nắm bắt đối với những người cho rằng nhiều chủ đề luôn tốt hơn. Logic là nếu chi phí chung của việc thêm tác vụ vào luồng khác lớn hơn thời gian bạn lưu, thì có thể nhanh hơn để thực hiện tác vụ trong luồng hiện tại.

Một cách khác là sử dụng một nhóm chủ đề. Một nhóm chủ đề có thể hiệu quả hơn vì hai lý do. 1) nó sử dụng lại các chủ đề đã được tạo. 2) bạn có thể điều chỉnh / kiểm soát số lượng chủ đề để đảm bảo bạn có hiệu suất tối ưu.

Các chương trình sau in ....

Time for a task to complete in a new Thread 71.3 us
Time for a task to complete in a thread pool 0.39 us
Time for a task to complete in the same thread 0.08 us
Time for a task to complete in a new Thread 65.4 us
Time for a task to complete in a thread pool 0.37 us
Time for a task to complete in the same thread 0.08 us
Time for a task to complete in a new Thread 61.4 us
Time for a task to complete in a thread pool 0.38 us
Time for a task to complete in the same thread 0.08 us

Đây là một thử nghiệm cho một nhiệm vụ tầm thường phơi bày chi phí hoạt động của từng tùy chọn luồng. (Nhiệm vụ thử nghiệm này là loại nhiệm vụ thực sự được thực hiện tốt nhất trong luồng hiện tại.)

final BlockingQueue<Integer> queue = new LinkedBlockingQueue<Integer>();
Runnable task = new Runnable() {
    @Override
    public void run() {
        queue.add(1);
    }
};

for (int t = 0; t < 3; t++) {
    {
        long start = System.nanoTime();
        int runs = 20000;
        for (int i = 0; i < runs; i++)
            new Thread(task).start();
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in a new Thread %.1f us%n", time / runs / 1000.0);
    }
    {
        int threads = Runtime.getRuntime().availableProcessors();
        ExecutorService es = Executors.newFixedThreadPool(threads);
        long start = System.nanoTime();
        int runs = 200000;
        for (int i = 0; i < runs; i++)
            es.execute(task);
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in a thread pool %.2f us%n", time / runs / 1000.0);
        es.shutdown();
    }
    {
        long start = System.nanoTime();
        int runs = 200000;
        for (int i = 0; i < runs; i++)
            task.run();
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in the same thread %.2f us%n", time / runs / 1000.0);
    }
}
}

Như bạn có thể thấy, việc tạo một chủ đề mới chỉ tốn ~ 70. Điều này có thể được coi là tầm thường trong nhiều trường hợp, nếu không phải hầu hết, các trường hợp sử dụng. Nói một cách tương đối, nó đắt hơn các lựa chọn thay thế và trong một số trường hợp, một luồng xử lý hoặc không sử dụng các luồng là một giải pháp tốt hơn.


8
Đó là một đoạn mã tuyệt vời ở đó. Súc tích, đến điểm và hiển thị rõ ràng ý chính của nó.
Nicholas

Trong khối cuối cùng, tôi tin rằng kết quả bị sai lệch, bởi vì trong hai khối đầu tiên, luồng chính được loại bỏ song song khi các luồng công nhân đang đặt. Tuy nhiên, trong khối cuối cùng, hành động lấy tất cả được thực hiện một cách thanh thản, do đó, nó làm giãn giá trị. Bạn có thể có thể sử dụng queue.clear () và sử dụng CountDownLatch thay vào đó để chờ các luồng hoàn thành.
Victor Grazi

@VictorGrazi Tôi giả sử bạn muốn thu thập kết quả tập trung. Nó đang làm cùng một lượng công việc xếp hàng trong mỗi trường hợp. Một chốt đếm ngược sẽ nhanh hơn một chút.
Peter Lawrey

Trên thực tế, tại sao không chỉ cần nó làm một cái gì đó liên tục nhanh chóng, như tăng một bộ đếm; thả toàn bộ điều BlockingQueue. Kiểm tra bộ đếm ở cuối để ngăn trình biên dịch tối ưu hóa hoạt động gia tăng
Victor Grazi

@grazi bạn có thể làm điều đó trong trường hợp này nhưng trong hầu hết các trường hợp thực tế, việc chờ đợi trên quầy có thể không hiệu quả. Nếu bạn đã làm điều đó, sự khác biệt giữa các ví dụ sẽ còn lớn hơn.
Peter Lawrey

31

Về lý thuyết, điều này phụ thuộc vào JVM. Trong thực tế, mỗi luồng có một lượng bộ nhớ ngăn xếp tương đối lớn (tôi nghĩ là 256 KB). Ngoài ra, các luồng được triển khai dưới dạng các luồng của HĐH, vì vậy việc tạo chúng liên quan đến một cuộc gọi HĐH, tức là chuyển đổi ngữ cảnh.

Bạn có nhận ra rằng "đắt" trong điện toán luôn rất tương đối. Tạo chủ đề rất tốn kém so với việc tạo ra hầu hết các đối tượng, nhưng không đắt lắm so với tìm kiếm ổ cứng ngẫu nhiên. Bạn không phải tránh tạo chủ đề bằng mọi giá, nhưng tạo hàng trăm trong số chúng mỗi giây không phải là một bước đi thông minh. Trong hầu hết các trường hợp, nếu thiết kế của bạn yêu cầu nhiều luồng, bạn nên sử dụng nhóm luồng có kích thước giới hạn.


9
Btw kb = kilo-bit, kB = kilo byte. Gb = bit giga, GB = giga byte.
Peter Lawrey

@PeterLawrey chúng ta viết hoa chữ 'k' trong 'kb' và 'kB', vậy có đối xứng với 'Gb' và 'GB' không? Những điều này làm tôi khó chịu.
Jack

3
@Jack Có a K= 1024 và k= 1000 .;) en.wikipedia.org/wiki/Kibibyte
Peter Lawrey

9

Có hai loại chủ đề:

  1. Các luồng thích hợp : đây là các tóm tắt xung quanh các tiện ích luồng của hệ điều hành bên dưới. Do đó, việc tạo chủ đề cũng tốn kém như hệ thống - luôn có chi phí hoạt động.

  2. Các luồng "Xanh" : được tạo và lên lịch bởi JVM, các luồng này rẻ hơn, nhưng không xảy ra hiện tượng paralellism thích hợp. Chúng hoạt động giống như các luồng, nhưng được thực thi trong luồng JVM trong HĐH. Chúng không thường được sử dụng, theo hiểu biết của tôi.

Yếu tố lớn nhất tôi có thể nghĩ đến trong chi phí tạo luồng, là kích thước ngăn xếp mà bạn đã xác định cho các luồng của mình. Kích thước ngăn xếp luồng có thể được truyền dưới dạng tham số khi chạy VM.

Ngoài ra, việc tạo luồng chủ yếu phụ thuộc vào hệ điều hành và thậm chí phụ thuộc vào việc triển khai VM.

Bây giờ, hãy để tôi chỉ ra một điều: việc tạo các chủ đề rất tốn kém nếu bạn dự định bắn 2000 luồng mỗi giây, mỗi giây trong thời gian chạy của bạn. JVM không được thiết kế để xử lý điều đó . Nếu bạn có một vài công nhân ổn định sẽ không bị sa thải nhiều lần, hãy thư giãn.


19
"... một vài công nhân ổn định sẽ không bị sa thải và bị giết ..." Tại sao tôi lại bắt đầu nghĩ về điều kiện nơi làm việc? :-)
Stephen C

6

Tạo Threadsyêu cầu phân bổ một lượng bộ nhớ hợp lý vì nó phải tạo ra không phải một, mà là hai ngăn xếp mới (một cho mã java, một cho mã gốc). Việc sử dụng Executor / Thread Pools có thể tránh được chi phí hoạt động, bằng cách sử dụng lại các luồng cho nhiều tác vụ cho Executor .


@Raedwald, jvm sử dụng ngăn xếp riêng biệt là gì?
bestsss

1
Philip JP nói 2 ngăn xếp.
Raedwald

Theo như tôi biết, tất cả các JVM đều phân bổ hai ngăn xếp cho mỗi luồng. Nó rất hữu ích cho bộ sưu tập rác để xử lý mã Java (ngay cả khi JITed) khác với phân phối tự do c.
Philip JF

@Philip JF Bạn có thể giải thích rõ hơn không? Ý bạn là gì khi 2 ngăn xếp một cho mã Java và một cho mã gốc? Nó làm gì?
Gurinder

"Theo như tôi biết, tất cả các JVM đều phân bổ hai ngăn xếp cho mỗi luồng." - Tôi chưa bao giờ thấy bất kỳ bằng chứng nào sẽ hỗ trợ điều này. Có lẽ bạn đang hiểu sai bản chất thực sự của opstack trong đặc tả JVM. (Đó là một cách mô hình hóa hành vi của mã byte, không phải là thứ cần được sử dụng trong thời gian chạy để thực thi chúng.)
Stephen C

1

Rõ ràng mấu chốt của câu hỏi là 'đắt' nghĩa là gì.

Một luồng cần tạo một ngăn xếp và khởi tạo ngăn xếp dựa trên phương thức chạy.

Nó cần phải thiết lập các cấu trúc trạng thái điều khiển, nghĩa là trạng thái của nó đang chạy, chờ đợi, v.v.

Có lẽ có rất nhiều sự đồng bộ hóa xung quanh việc thiết lập những thứ này.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.